Piggyback

An extension for piggybacking of ActiveRecord™ models.

What is Piggybacking?

Piggybacking refers to the technique of dynamically including attributes from an associated model. This is achieved by joining the associated model in a database query and selecting the attributes that should be included with the parent object.

This is best illustrated in an example. Consider these models:

class User < ActiveRecord::Base
  has_many :posts
end

class Post < ActiveRecord::Base
  belongs_to :user
end

ActiveRecord supports piggybacking simply by joining the associated table and selecting columns from it:

post = Post.select('posts.*, user.name AS user_name') \
           .joins("JOIN users ON posts.user_id = users.id") \
           .first

post.title      # => "Why piggybacking in ActiveRecord is flawed"    
post.user_name  # => "Alec Smart"

As you can see, the name attribute from User is treated as if it were an attribute of Post. ActiveRecord dynamically determines a model's attributes from the result set returned by the database. Every column in the result set becomes an attribute of the instantiated ActiveRecord objects. Whether the columns originate from the model's own or from a foreign table doesn't make a difference.

Or so it seems. Actually there is a drawback which becomes obvious when we select non-string columns:

post = Post.select('posts.*, user.birthday AS user_birthday, user.rating AS user_rating') \
           .joins("JOIN users ON posts.user_id = users.id") \
           .first

post.user_birthday  # => "2011-03-01"
post.user_rating    # => "4.5"

Any attributes originating from the users table are treated as strings instead of being automatically type-casted as we would expect. The database returns result sets as plain text and ActiveRecord needs to obtain type information separately from the table schema in order to do its type-casting magic. Unfortunately, a model only knows about the columns types in its own table, so type-casting doesn't work with columns selected from foreign tables.

We could work around this by defining attribute reader methods in the Post model that implicitly convert the values:

class Post < ActiveRecord::Base
  belongs_to :user

  def user_birthday
    Date.parse(read_attribute(:user_birthday))
  end

  def user_rating
    read_attribute(:user_rating).to_f
  end
end

However this is tedious, error-prone and repetitive if you do it in many models. The type-casting code shown above isn't solid and would quickly become more complex in a real-life application. In its current state it won't handle nil values properly, for example.

Piggyback to the rescue!

Piggyback introduces the piggybacks directive which allows us to easily define which attributes we want to piggyback from associated models. Not only does it take care of the type-casting but also provides us with some additional benefits.

You simply declare which association you want to piggyback and how the attribute names should be mapped:

class Post < ActiveRecord::Base
  belongs_to :user

  piggyback_attr :name, :email, { :user_rating => :rating , :member_since => :created_at }, :from => :user
end

Now you can do the following:

posts = Post.piggiback(:name, :email, :user_rating, :member_since)
post = posts.first

post.name          # => "John Doe"
post.user_rating   # => 4.5
post.member_since  # => Tue, 01 Mar 2011

The type-casting works with any type of attribute, even with serialized ones.

Since we selected all attributes from the user, we could alternatively have used:

posts = Post.piggiback(:from => :user)

Or we could have as well selected all piggiback attributes simply with:

posts = Post.piggiback

As you can see, the piggibacks statement replaces the joins and select parts of the query. Using it is optional but makes life easier. In certain situations however, you may want to select columns by hand or use another kind of join for related table. By default piggibacks uses OUTER JOIN in order to include both records that have an associated record and ones that don't.

Of course, piggibacks plays nice with Arel and you can add additional joins, select and other statements as you like, for example:

Post.select('posts.id, posts.body').piggiback(:name, :email).where(:published => true)

Please note: If you want to restrict the columns selected from the master table as in the example above, you have to do so before the piggibacks statement. Otherwise it will insert the select-all wildcard SELECT posts.* rendering your column selection useless.

If you don't need to map the attribute names of the piggybacked model, you can simply do:

piggyback_attr :name, :birthday, :rating, :from => :user

Computed values

If you want to use an SQL-expression for selecting an attribute, Piggyback can also help you with that. If User didn't have a single name attribute, but first_name and last_name, you could concatenate them into a single attribute:

class Post < ActiveRecord::Base
  belongs_to :user
  piggyback_attr ({ :user_name => "users.first_name || ' ' || users.last_name" }), :from => :user,  
end

post.user_name  # => "Donald Duck"

Or you could compute a numeric value. In this case however, you'll want to add the name of the original column to get proper type casting for the computed value. If you don't specifiy the column name, the result be a string instead of a float. You specify the column by passing an array containing the SQL statement and the name of the column:

class Post < ActiveRecord::Base
  belongs_to :user
  piggyback_attr ({ :monthly_salary  => [ "users.salary / 12", :salary ] }), :from => :user,  
end

post.monthly_salary  # => 1300.0