URLs on Rails
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
July 7th, 2006 at 10:51 am
Hi. I learned something new.
I have a few questions. What would happen if there are other numbers on the url? For example, 12-john-23. And wouldn’t an extra param be better, like edit/12?john-doe ?
July 7th, 2006 at 11:25 am
The number conversion routine actually stops on the first non-numeric character. So “12-25″ is converted to “12″.
Using an extra parameter means explicitly passing it to url_for/link_to, which would be extremelly inconvenient.
July 7th, 2006 at 12:17 pm
I’m slowly coming around to thinking this may be a good approach. It’s not “ideal”, but it’s pragmatic and seems to have some advantages. I think the biggest challenge may be getting users comforable with the change.
By the way, have you seen this article before? It suggests exactly the same thing. http://plasmasturm.org/log/358/
July 7th, 2006 at 12:19 pm
I’m on the fence about this. My biggest qualm is that if you’ve got a route like /users/12-john-doe the route /users/12-big-poop-face would also work. That seems bad.
July 7th, 2006 at 12:21 pm
Scarily similar ideas :-)
The permanent redirect is definitelly the right thing to do if you really want to preserve the RESTfulness of your URLs.
July 7th, 2006 at 7:40 pm
sd, I got your point about the extra param. I’ll definitely try this one.
July 7th, 2006 at 11:22 pm
Phil, if you wanted to do more work, object 12 could remember its slug history and only match ones that used to be valid. What’s the downside of matching “incorrect” slugs if the ID part is correct? Worried about spiders?
July 8th, 2006 at 6:55 pm
This is a really good idea but doesn’t catch accented characters. I’ve used gsub(/&([aeiou])[acute|grave];/, “\\1″) to catch the basic accented characters [that regex might be off, I’m going on memory] but you’ll definitely need to make your code handle accented characters.
July 8th, 2006 at 7:11 pm
If it’s really sending the whole string to the database, it’s going to fail on any database that does remotely correct type coercions, which is pretty much every one except for mysql and sqlite.
Postgresql in particular will error out if you pass in something as innocous as 1.0 in for an integer.
July 8th, 2006 at 7:40 pm
> object 12 could remember its slug history
A better option, imho, would be to permanently redirect all “wrong” slugs to the correct one. This is much simpler to redirect and more robust.
July 11th, 2006 at 11:31 am
I was following your how to on adding slugs to the id’s of records. However, I ran into a problem with your coercion to integer snippet. The problem is that the alias_method calls were trying to alias instance methods, but should have been aliasing class methods. The alias_method calls need to be in a “class
class ActiveRecord::Base
def self.find_from_ids_with_coercion(ids, options)
find_from_ids_without_coercion(ids.collect(&:to_i), options)
end
class
July 11th, 2006 at 11:33 am
trying code snippet again:
class ActiveRecord::Base
def self.find_from_ids_with_coercion(ids, options)
find_from_ids_without_coercion(ids.collect(&:to_i), options)
end
class << self
alias_method :find_from_ids_without_coercion, :find_from_ids
alias_method :find_from_ids, :find_from_ids_with_coercion
end
end
July 12th, 2006 at 9:41 am
How about
/forums/show/11/test-forum-for-rails instead of /forums/show/11-test-forum-for-rails?
In the former, you get a clean :id, and you just discard the readable text?
July 12th, 2006 at 10:28 am
the problem with something like “/forums/show/11/test-forum-for-rails” is that since the slug is a separate part, you have to include it explicitly in your url_for / link_to calls.
So instead of just doing
url_for :controller => “forums”, :action => “show”, :id => @forum
you need to do
url_for :controller => “forums”, :action => “show”, :id => @forum, :slug => @forum.slug
thus losing all advantages provided by the to_param method.
July 12th, 2006 at 7:52 pm
[…] the permalink coolness of this dude […]
July 15th, 2006 at 2:03 am
Good idea, and I’ll be using it when I rewrite my blog in Rails. For my photo gallery though, I’ve got a hierarchical structure (Folders contain Rolls which contain Photos). I wanted URLs that reflected that hierarchy, so that if a user wanted to navigate to the roll that contains a given photo she could just delete the last component of the URL. The solution I came up with is not nearly as clean as yours, but I think it’s not too bad. I’ve got a series of named routes in config/routes.rb (let’s see if I can paste code here) :
map.folder ‘:id’, :controller => ‘folders’, :action => ’show’,
:requirements => { :id => /\d+/ }
map.roll ‘:folder_id/:id’, :controller => ‘rolls’, :action => ’show’,
:requirements => { :id => /\d+/, :folder_id => /\d+/ }
map.photo ‘:folder_id/:roll_id/:id’, :controller => ‘photos’, :action => ’show’,
:requirements => { :id => /\d+/, :roll_id => /\d+/, :folder_id => /\d+/ }
And a helper method in app/controllers/application.rb:
def pretty_url_for(object, *args)
options = pretty_url_args_to_hash(*args)
options[:flags] ||= []
for flag in options[:flags] do
options[flag.to_sym] = “”
end
options.delete :flags
if object.is_a?(Folder)
return folder_url(options.merge({ :id => object.id }))
elsif object.is_a?(Roll)
return roll_url(options.merge({ :id => object.id,
:folder_id => object.folder.id }))
elsif object.is_a?(Photo)
if options[:size].nil?
return photo_url(options.merge({ :id => object.id,
:roll_id => object.roll.id,
:folder_id => object.roll.folder.id }))
else
return photo_raw_url(options.merge({ :id => object.id,
:roll_id => object.roll.id,
:folder_id => object.roll.folder.id }))
end
elsif object.to_sym == :root
return url_for(options.merge({ :controller => ‘folders’,
:action => ‘list’ }))
else
return url_for(options)
end
end
This results in urls like http://example.com/photos/2/36/164, of which only the ‘164′ is used to find the photo in question. Not the prettiest, but better than the default, I think. I’ll see if I can rework it to use your technique to make the URL look a bit better.
July 21st, 2006 at 6:42 pm
Nice work and a good idea. I implemented this on a site I’m building and figured it would be worth it to extract it out into a plugin so I (and others) can reuse it. Thus, acts_as_sluggable was born: http://agilewebdevelopment.com/plugins/acts_as_sluggable
July 24th, 2006 at 3:47 pm
It doesn’t work with postgresql - therefore it’s bad idea ;))
July 28th, 2006 at 8:11 pm
It doesn’t work with PostgreSQL if you send the whole slug as an ID, but when removing it beforehand it does.
July 31st, 2006 at 3:19 pm
I tried to implement this on my app using mysql. I got the following error from my controller: ActiveRecord::StatementInvalid (Mysql::Error: Unknown column ‘Acid’ in ‘where clause’: SELECT id FROM artists WHERE (artists.id = 5-Acid-Mothers-Temple) LIMIT 1):
So, I tried the fix suggested for other DBs. The one in the body of the post led to this error when booting rails:
Exiting
./script/../config/../lib/sluggable.rb:5:in `alias_method’: undefined method `find_from_ids’ for class `ActiveRecord::Base’ (NameError)
and the one suggested in the comments by Alex Wayne gave this error: Illegal instruction when I tried to load a page.
Any ideas?
August 3rd, 2006 at 3:15 am
I have 2 controller classes: Projects and Documents. I want the Documents.list to list only the documents of a particular project. So, how can I send a project’s id to Document’s methods?
I tried to do this in the Project class:
“:controller => ‘Document’, :action => ‘list’, :id => @project”
And in the Documents class, I used Project.find(params[:id]) to get the project. But that didn’t work, it “couldn’t get the project without an id”!
Please help. Thank you in advance.
August 16th, 2006 at 7:13 pm
This is definetely worthwhile to try out for the rails ids situation where you dont want the ugly urls. Problem is, clean URI’s could also be modeled with another method that involved using rewrites to clean up the URI’s. Just requires some dubious hacking. - ben @ http://rubyonrailsblog.com/
August 22nd, 2006 at 6:49 am
This is the thing was searching for.
Thanks
September 12th, 2006 at 10:48 pm
I’m confused.. why u know to maintain the id in the url, you could have implemented pretty url using routing…. why do you need to go through all this? Am I missing something?
October 18th, 2006 at 12:31 pm
Sweet. As my signature link indicated, I’ve now actually taken to modifying my Typo permalinks to include the ID-title. Typo already has friendly permalinks, but this would allow for making mutable “perma”links that still worked, as you mention.
Hell, I could probably tweak the couple filed required myself.
October 24th, 2006 at 3:02 pm
I actually ended up rolling my Typo code to do this into a plugin. Those on Typo might want to check it out: typo-permalink-with-id.
October 25th, 2006 at 9:50 am
This doesn’t seem to work with rails 1.1.6 - it never calls to_param, which seems to be black-magic aliased with #id. Any ideas?
October 25th, 2006 at 9:55 am
Nevermind - I had two models that were overriding each other!
November 2nd, 2006 at 2:34 pm
I think that in the regular expression there isn’t a 0.
/[^a-z1-9]+/i
Should be
/[^a-z0-9]+/i
Bye
November 26th, 2006 at 3:18 pm
[…] ¿Y como lo he hecho? Pues primero busqué en google, y encontré este link donde explican una manera de hacerlo. La verdad que ese método no me convencia mucho y no era nada elegante, así que paré de buscar y me puse a pensar y a recordar el libro donde hablaban del tema de las routas en Rails, así que por ahi creia que estaba la solución. […]
April 4th, 2007 at 2:59 am
for all this characters you don’t want in an url..
INT = {’ÀÁÂÃÅĀĄĂ’ => ‘A’, ‘Ä’ => ‘Ae’, ‘àáâãåāąă’ => ‘a’, ‘ä’ => ‘ae’, ‘Æ’ => ‘AE’, ‘æ’ => ‘ae’, ‘ÇĆČĈĊ’ => ‘C’, ‘çćčĉċ’ => ‘c’, ‘ĎĐ’ => ‘D’, ‘ďđ’ => ‘d’, ‘ÈÉÊËĒĘĚĔĖ’ =>’E', ‘èéêëēęěĕė’ =>’e', ‘ƒ’ => ‘f’, ‘ĜĞĠĢ’ => ‘G’, ‘ĝğġģ’ => ‘g’, ‘ĤĦ’ => ‘H’, ‘ĥħ’ => ‘h’, ‘ÌÍÎÏĪĨĬĮİ’ =>’I', ‘ìíîïīĩĭįı’ =>’i', ‘IJ’ => ‘IJ’, ‘Ĵ’ => ‘J’, ‘ĵ’ => ‘j’, ‘Ķ’ => ‘K’, ‘ķĸ’ => ‘k’, ‘ŁĽĹĻĿ’ => ‘L’, ‘łľĺļŀ’ => ‘l’, ‘ÑŃŇŅŊ’ => ‘N’, ‘ñńňņʼnŋ’ => ‘n’, ‘ÒÓÔÕØŌŐŎ’ => ‘O’, ‘Ö’ => ‘Oe’, ‘òóôõøōőŏ’ => ‘o’, ‘ö’ => ‘oe’, ‘Œ’ => ‘OE’, ‘œ’ => ‘oe’, ‘ŔŘŖ’ =>’R', ‘ŕřŗ’ =>’r', ‘ŚŠŞŜȘ’ => ‘S’, ‘śšşŝș’ => ’s’, ‘ŤŢŦȚ’ => ‘T’, ‘ťţŧț’ => ‘t’, ‘ÙÚÛŪŮŰŬŨŲ’ =>’U', ‘Ü’ => ‘Ue’, ‘ùúûūůűŭũų’ =>’u', ‘ü’ => ‘ue’, ‘Ŵ’ => ‘W’, ‘ŵ’ => ‘w’, ‘ÝŶŸ’ =>’Y', ‘ýÿŷ’ =>’y', ‘ŹŽŻ’ =>’Z', ‘žżź’ =>’z'}
def exchange_special_chars(string)
INT.each do |key, value|
string =string.gsub %r([#{key}]), value
end
string
end
April 18th, 2007 at 2:55 pm
My problem is that when I use this technique, sweepers cease to expire pages correctly, since they will look to expire the un-slugged version of the page (which doesn’t exist). Has anybody else experienced this issue, and maybe found a workaround for it?
May 30th, 2007 at 3:41 pm
instead of using the scary regexp or the |key,value| replacement, I tacked on a method to String that spits back a CGI::escape()’ed version of the string. I came from a PHP background and urlencode(); was just too handy. with the String::url_escape() method, all one needs to do in a to_param method is “#{id}-#{name.url_escape}” and all non-valid characters will be converted into their URL entities.
To do this, I dropped the following pastie into my application.rb:
http://pastie.textmate.org/66219
April 23rd, 2008 at 9:13 pm
map <a href="http://kPMVpfJP3CSh.nm.ru/map.html" rel="nofollow">map</a> <a href="http://kPMVpfJP3CSh.nm.ru/felice_gimondi.html" rel="nofollow">felice gimondi</a> [url=http://kPMVpfJP3CSh.nm.ru/felice_gimondi.html]felice gimondi[/url] <a href="http://kPMVpfJP3CSh.nm.ru/felice_anno_nuovo_bielorusso.html" rel="nofollow">felice anno nuovo bielorusso</a> [url=http://kPMVpfJP3CSh.nm.ru/felice_anno_nuovo_bielorusso.html]felice anno nuovo bielorusso[/url] <a href="http://kPMVpfJP3CSh.nm.ru/san_felice_del_molise_ristorante.html" rel="nofollow">san felice del molise ristorante</a> [url=http://kPMVpfJP3CSh.nm.ru/san_felice_del_molise_ristorante.html]san felice del molise ristorante[/url] <a href="http://kPMVpfJP3CSh.nm.ru/relais_chateaux_borgo_san_felice_siena.html" rel="nofollow">relais chateaux borgo san felice siena</a> [url=http://kPMVpfJP3CSh.nm.ru/relais_chateaux_borgo_san_felice_siena.html]relais chateaux borgo san felice siena[/url] <a href="http://kPMVpfJP3CSh.nm.ru/teatro_carlo_felice_genova.html" rel="nofollow">teatro carlo felice genova</a> [url=http://kPMVpfJP3CSh.nm.ru/teatro_carlo_felice_genova.html]teatro carlo felice genova[/url] <a href="http://kPMVpfJP3CSh.nm.ru/frase_biglietto_ricordo_prima_comunione.html" rel="nofollow">frase biglietto ricordo prima comunione</a> [url=http://kPMVpfJP3CSh.nm.ru/frase_biglietto_ricordo_prima_comunione.html]frase biglietto ricordo prima comunione[/url] <a href="http://kPMVpfJP3CSh.nm.ru/chianti_sina_hotel_san_felice.html" rel="nofollow">chianti sina hotel san felice</a> [url=http://kPMVpfJP3CSh.nm.ru/chianti_sina_hotel_san_felice.html]chianti sina hotel san felice[/url] <a href="http://kPMVpfJP3CSh.nm.ru/ricordo_store_milano_offerta_lavoro.html" rel="nofollow">ricordo store milano offerta lavoro</a> [url=http://kPMVpfJP3CSh.nm.ru/ricordo_store_milano_offerta_lavoro.html]ricordo store milano offerta lavoro[/url] map <a href="http://outrageoutst.krovatka.su/map.html" rel="nofollow">map</a> <a href="http://outrageoutst.krovatka.su/yurist_rabota_moskva_vakansiya.html" rel="nofollow"> </a> [url=http://outrageoutst.krovatka.su/yurist_rabota_moskva_vakansiya.html] [/url] <a href="http://outrageoutst.krovatka.su/korr_ooo.html" rel="nofollow"> </a> [url=http://outrageoutst.krovatka.su/korr_ooo.html] [/url] <a href="http://outrageoutst.krovatka.su/izmenenie_rinka.html" rel="nofollow"> </a> [url=http://outrageoutst.krovatka.su/izmenenie_rinka.html] [/url] <a href="http://outrageoutst.krovatka.su/zhurnal_krilya_rodini.html" rel="nofollow"> </a> [url=http://outrageoutst.krovatka.su/zhurnal_krilya_rodini.html] [/url] <a href="http://outrageoutst.krovatka.su/stoimost_biletov_novorossijsk.html" rel="nofollow"> </a> [url=http://outrageoutst.krovatka.su/stoimost_biletov_novorossijsk.html] [/url] <a href="http://outrageoutst.krovatka.su/prikolnie_zaprosi_poiskovih_sistem.html" rel="nofollow"> </a> [url=http://outrageoutst.krovatka.su/prikolnie_zaprosi_poiskovih_sistem.html] [/url] <a href="http://outrageoutst.krovatka.su/sentyabr_kalendar_sobitij.html" rel="nofollow"> </a> [url=http://outrageoutst.krovatka.su/sentyabr_kalendar_sobitij.html] [/url] <a href="http://outrageoutst.krovatka.su/belorusskaya_obuv_moskva.html" rel="nofollow"> </a> [url=http://outrageoutst.krovatka.su/belorusskaya_obuv_moskva.html] [/url]