Permalinks on Rails
Friday, December 18th, 2009 · 0 comments
Rails provides easy ways to change a resource's URI by overriding the to_param method in a model. By default, it's the model's id column.
def to_param
"#{self.name.parameterize}"
end
This snippet of code will change the models URI to the name column (the parameterize method converts it to lower case and adds dashes where spaces and punctuation were). By storing this in the database, it's as easy as Person.find_by_permalink(params[:id]).
class Person < ActiveRecord::Base
before_save :generate_permalink
# ...
private
def generate_permalink
self.permalink = self.name.parameterize
end
end
This also updates pre-generated routes, so person_path(@person) would correlate to /people/mat-harvard, same with edit_person_path(@person) » /people/mat-harvard/edit. It's a simple way of achieving pretty URLs. The only thing that possibly needs watching out for is duplicates, but that's not what I'm writing about.
So getting Rails to use a different column as a model's id is simple. What if we want something totally different? For example, many blogging engines have dates in their permalinks, like /2009/08/01/posting-for-fun. A popular blog engine, Mephisto can have permalinks like these, as well as (now inactive) SimpleLog. While you could peruse their code and figure out from there (Mephisto's is actually a little more technical mind), I wanted my own clean solution.
The first step was setting up the routing file to pick-up on these types of permalinks. Luckily, the Rails documentation has a great example of just how to do that.
map.connect 'articles/:year/:month/:day',
:controller => 'articles',
:action => 'find_by_date',
:year => /\d{4}/,
:month => /\d{1,2}/,
:day => /\d{1,2}/
That leaves us with these params = { :year => '2005', :month => '11', :day => '06' }. Let's assume this route is meant to find all posts on November 11th, 2005. An article written back in 2007 has a clear and simple solution to such a conundrum: The Rails Way: More Idiomatic Ruby. After a bit of tinkering, I came up with a class method.
def find_by_year_and_month_and_day(year, month, day)
requested_date = Date.new(year.to_i, month.to_i, day.to_i)
from = requested_date - 1
to = requested_date + 1
posts = find(:all, :conditions => ["created_at BETWEEN ? AND ?", from, to])
end
From that you could figure out how to find all records within a specific month or year (look at the Date class documentation for help).
What I Want
Within the next week or so I'll be rolling out a new permalink system for this blog. It uses much the same technique as outlined above. The big difference is that I wanted permalinks like /2009/dec/18/posting-for-fun. Some of you may recognize this as being Django inspired. In fact I went and browsed the source code of the Django blog, and The B-List, and stole (!) the routes.
map.connect ':year/:month/:day',
:controller => 'posts',
:action => 'show',
:year => /\d{4}/,
:month => /\w{3}/,
:day => /\d{2}/
Having the short month name in the URL actually didn't complicate things as much as I thought it would. Date provides an easy method for converting this to the proper number of the month.
def find_by_year_and_month_and_day(year, month, day)
requested_date = Date.new(year.to_i, Date.parse(month).month.to_i, day.to_i)
from = requested_date - 1
to = requested_date + 1
posts = find(:all, :conditions => ["created_at BETWEEN ? AND ?", from, to])
end
Hopefully that isn't bad programming... (using Date inside a Date to get a Date, and not someone else). The app now knows what do do with a fancy URL, but not how to generate one. Overriding to_param in this case could potentially get very messy, if it would even work in the first place. Going by the excellent examples of Mephisto and SimpleLog, I wrote this instance method inside of my Post model.
def full_permalink
['', published_at.year, published_at.strftime("%b").downcase, published_at.strftime("%d"), slug] * '/'
end
Call @post.full_permalink and you get it's full permalink (for example link_to(@post.title, @post.full_permalink)). Clear and simple (at least to me). I extended this and wrote methods for finding posts by a specific month in a year, and a just year (also wrote corresponding routes to match). Nested resources like comments didn't mesh with the routes, so I had to change how they worked slightly. That's all I've got to say right now! I look forward to getting this out on my blog soon.
