Olivier Lacan

Software bricoleur, word wrangler, scientific skeptic, and logic lumberjack.

Migrating an ad-hoc URL slug system to FriendlyId

Written on April 16, 2015 in Paris, France

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.