Archive for July, 2006

URLs on Rails

Friday, July 7th, 2006

One of the parts of rails that some people consider “ugly” and go to greath lenghts to “clean up” is the use of numeric ids in URLs: /accounts/edit/12.

URLs are considered extremely valuable real estate. Not only because users have to see them all the time, but also because search engines give them a lot of weight: since it’s a “limited resource” where you can only include a few keywords, you better use the keywords that matter most.

Rails does an excellent effort to help you use nice and clean URLs, but it stops at the :id. And it does this for one good reason. If it were to use, say, a user’s login instead of it’s numeric ID, then when a user changed his login, old URLs would no longer be valid. Yeah, I can hear you say that users don’t change logins, but what about a blog entry title? or a person’s full name? or a project name? As soon as you use some user-editable piece of information in your URLs you create the problem of state URLs, and that’s even worse than ugly URLs.

But there is a very simple solution. Use both a permanent id and a nicer textual description. Instead of an ugly /accounts/edit/12 or a perishable /accounts/edit/john-doe why not use /accounts/edit/12-john-doe. Your code has the “12″ it needs to look for the user, even if it later changed his name to “john-d-doe”, and your users and search spiders have the “john-doe” to feast on.

Implementing this is extremely simple, because Rails treats :id as a special parameter in routes. It’s specialness comes from the fact that it would try to call the to_param method on any object passed when creating URLs. That’s why url_for :id => @account is equivalent to url_for :id => @account.id, because ActiveRecord model’s have a default to_param that returns the id of the object.

All you need to do is define your own to_param for your models, and make sure you don’t explicitly include the .id in your url_fors and link_tos, because then you would be skipping your own to_param call.

class Account < ActiveRecord::Base
  def to_param
    "#{id}-#{full_name.gsub(/[^a-z1-9]+/i, '-')}"
  end
end

The second part of this solution is, of course, making sure your actions can handle these extended :ids. The smart ones amongst you would immediately think about monkey patching ActiveRecord’s find to clean up the parameters in calls like Account.find(params[:id]). But the not so stupid way would be to forget about dealing with this, giving it a try and look surprised when it works miraculosly as expected.

See, this is just a coincidence; it was not designed as part of Rails (or DHH wouldn’t have looked surprised when I brought this up at RailsConf). It’s just that Rails will pass your long :id string to the database server, which, on seeing that the id column is actually an integer, will try to convert the parameter to a number before using it, and it happens that such conversion will just use any numerical characters it finds and drop the rest, thus converting “12-john-doe” into plain 12. See, accidental behaviour over configuration; what can be better than that?

Of course, you might want to add a couple of unit tests just to make sure whatever database server you’re using behaves in this particular way. I’m not sure if that’s part of the SQL-92 standard, but I would be surprised if any major database server works differently.

So there you have it, now go add useful information to your URLs, like “asbestos-mesothelioma-canada-drugs-viagra-ambien”.

Oh, and in case you were wondering, hyphens/dashes (-) work better as word separators than any other characters. Google will match “canada drugs” against an URL like canada_drugs, but it won’t match “canada” alone. If you use hyphens, as in “canada-drugs”, then it considers them as separate, independent words.

Update: Some good points have been raised in the comments.

First, is that Aristotle Pagaltzis brought up pretty much this same argument more than six months ago.

Second, is that you might want to use redirects from any partially valid (i.e. the ID is correct, but not the rest of the slug) to the “official” URL. This is simple to implement, but requires some extra code on each controller.

Third, is that some database servers do not perform type coercion, and might get very angry if you don’t pass an integer for your id queries. Postgres was cited as an example. The solution for this is extremelly simple: just make sure this code is executed when your application starts (i.e. put it in lib and require it from environment.rb, make it a plugin, etc)

class ActiveRecord::Base
  def self.find_from_ids_with_coercion(id, options)
    find_from_ids_without_coercion(id.to_i, options)
  end
  alias_method :find_from_ids_without_coercion, :find_from_ids
  alias_method :find_from_ids, :find_from_ids_with_coercion
end

No need to try

Thursday, July 6th, 2006

I previously talked about our try method as an easy way of dealing with exceptions inside expressions:

puts try {patient.name.first_name} || "-- no name --"

Otherwise, you would have to set up your own begin / rescue / end blocks:

puts begin
  patient.name.first_name
rescue
  "-- no name --"
end

which would have been too ugly for our modern sentitivities.

I was wrong. I should have known it.

It turns out that ruby lets you use rescue without a begin. This is most commonly seen in exception handling for methods:

def my_method
  # do something
rescue
  # handle exception
end

It also turns out the ruby lets you use rescue as a postfix modifier for an expression, just like all those if and unless. So you can write something like:

puts patient.name.first_name rescue puts "-- no name --"

And it also happens that “inline rescues” work inside parenthesis. So you can do something like:

puts (patient.name.first_name rescue "-- no name --")

Which is exactly the use-case for our original try method.

So now that I had a piece of code blessed by _why, I’ll have to get rid of it or risk not beeing rubyish enough. You live and you learn.