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.
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_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 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
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.
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:
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
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.