I recently decided to migrate from a ad-hoc solution for URL slugs on Orientation to Norman Clarke’s excellent FriendlyId gem.
The main reason was that wanted to keep a slug history in order to avoid 404 File Not Found
errors when users decide to change an article title. I have to be able to guarantee that you will
find a piece of documentation regardless of how many time its title has changed in the past.
Before
This is how slugs were generated originally in the Article model:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# app/models/article.rb
class Article < ActiveRecord::Base
# ...
before_validation :generate_slug
# ...
validates :slug, uniqueness: true, presence: true
# ...
def to_param
slug
end
# ...
private
def generate_slug
if self.slug.present? && self.slug == title.parameterize
self.slug
else
self.slug = title.parameterize
end
end
end
And this is how they were used in the controller:
1
2
3
4
5
6
7
8
9
10
11
# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
respond_to :html, :json
# ...
def show
@article = Article.find_by(slug: params[:id]) || Article.find(params[:id])
respond_with @article
end
end
One important note here about find_by
. Unlike the regular ActiveRecord find
, find_by
does not raise a RecordNotFound
exception when it finds no matches. Instead it returns nil
which is falsey. This is why it comes before the find
call, which was only present as a legacy concern to allow people to access /articles/33
instead of using Article 33’s slug.
After
After installing FriendlyId and creating the friendly_id_slugs
table, I replaced the code above with this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# app/models/article.rb
class Article < ActiveRecord::Base
include Dateable
extend ActionView::Helpers::DateHelper
extend FriendlyId
friendly_id :title, use: [:slugged, :history]
def should_generate_new_friendly_id?
!has_friendly_id_slug || title_changed?
end
def has_friendly_id_slug?
slugs.where(slug: slug).exists?
end
end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
respond_to :html, :json
# ...
def show
@article = Article.friendly.find(params[:id])
respond_with_article_or_redirect
end
# ...
private
def respond_with_article_or_redirect
# If an old id or a numeric id was used to find the record, then
# the request path will not match the post_path, and we should do
# a 301 redirect that uses the current friendly id.
if request.path != article_path(@article)
return redirect_to @article, status: :moved_permanently
else
return respond_with @article
end
end
end
The useful method here is has_friendly_id_slug?
because it allows me to check
whether an article has a slug that was generated by FriendlyId or by my old
ad-hoc system.
FriendlyId creates a simple has_many
association between the model and FriendlyId::Slug
. I could have used friendly_id
(the dynamic reader method) because the default database column name for the locally-stored (on the articles
table instead of the friendly_id_slugs
table) slug is… slug
. That’s configurable of course, but I know for a fact that the column name I had used with my ad-hoc system was slug
so there’s no point in relying on FriendlyId to figure out the actual column name for me.
Why bother?
So anyway, say we have an article titled “Banana”. That article’s slug column should already be set to banana
(per my ad-hoc slug system) but a freshly installed FriendlyId should mean that there is not friendly_id_slugs
record for banana
. Thanks to has_friendly_id_slug?
, we can check.
And thanks to that check, we can decide to easily migrate all the cached slugs (i.e. on the articles
table) to FriendlyId. All it takes is:
1
Article.all.each(&:save!)
Why bother? Because if we’re using FriendlyId’s History module, we need its friendly_id_slugs
table to contain the original slug. Without doing this, the first slug stored in FriendlyId would be a future one (when an article’s title is eventually modified) and not the current one stored inside of the articles.slug
column.
1
2
3
4
5
6
a = Article.friendly.find("banana")
a.title = "Pamplemousse"
a.save!
a.friendly.find("banana")
=> #<Article:0x0000010b6913b8
Everybody’s happy and nobody bumps into 404s! The end.
PS: Thanks to Normal Clarke for the tip that helped me find this solution.