Learn Ruby on Rails Book

Adding slug to task

What is a slug

A slug is basically the part of a URL which identifies a page using human-readable keywords, rather than an opaque identifier such as a numeric id. This can make your application more friendly in the user front as well from an SEO standpoint.

With slug implementation, we can make our application use URLs like:

https://example.com/states/washington

instead of:

https://example.com/states/123

Out of the box, Rails automatically parses integer/UUID based IDs in URLs to access resources e.g. /tasks/1. That’s how routing works by default.

But what if the application requires pretty and readable URLs? So instead of /tasks/1, let's say we would like to have tasks/write-blog. So how could this be implemented with Rails?

Generating a slug to use in the URL

The generation process might depend on your own business logic requirements. For instance, it could be automatically created whenever a new task is added. Or, in other instance, admin users might need to add it manually. In any case, the slug needs to be stored in the database along with the other record attributes.

So, let's generate a migration to add slug attribute in our tasks table.

1bundle exec rails generate migration AddSlugToTask

We’ll need to ensure that our slug is unique, and always present. So, we will add following lines into the generated migration file:

1class AddSlugToTask < ActiveRecord::Migration[6.0]
2  def change
3    add_column :tasks, :slug, :string, null: false
4    add_index :tasks, :slug, unique: true
5  end
6end

The first line here simply adds a column named slug in our tasks table. We will be discussing about the second line in the handling race conditions section.

Run the command rails db:migrate to apply the migration.

1== 20210412132752 AddSlugToTask: migrated (0.0158s) ===========================

Also add the following line in the Task model.

1class Task < ApplicationRecord
2  belongs_to :user
3
4  validates :title, presence: true, length: { maximum: 50 }
5  validates :slug, uniqueness: true
6end

The uniqueness validation allows us to make sure that slug attributes value is unique just before it gets saved. Since we had already added a unique index constraint through migration for this model, we are fully enforcing uniqueness on all frontiers.

We also need to change routes to make use of slug instead of id on task resource route. Open /app/config/routes.rb and make the necessary change.

1Rails.application.routes.draw do
2  resources :tasks, only: [:index], param: :slug
3end

Setting a unique slug on creation

Let's create the slug automatically whenever a user creates a new Task. We will use before_create callback to set slug attribute. Why before_create?

The main reason for using the before_create validation is because we are only setting the slug value once and that is during the creation of a new task. Based on this use case, no other ActiveRecord callback would suffice over here.

Callback selection

Let's take a moment to truly understand why we chose the before_create callback in this context.

The first callback that might sound appealing to most developers is the before_save callback. But we shouldn't use before_save over here since it will be invoked for all updates, rather than just the first time creation of task.

after_save is another common callback for both create and update scenarios. But it's not practical to use it in our context, since it gets invoked while updating too and also we have to save the record once again. When we save it like that, it can end up in an infinite loop unless we conditionally exit.

But why not just use after_commit then? This callback should only be used when we queue a background job or tell another process about a change that we just made. Update of slug doesn't need to be announced to any other processes.

before_validation and after_validation are other callbacks that we might have in mind. But both of these callbacks should not be used as they will be invoked every time before any INSERT or UPDATE operation to the database.

Thus before_create is the right choice of a callback over here.

1class Task < ApplicationRecord
2  belongs_to :user
3
4  validates :title, presence: true, length: { maximum: 50 }
5  validates :slug, uniqueness: true
6
7  before_create :set_slug
8
9  private
10
11  def set_slug
12    itr = 1
13    loop do
14      title_slug = title.parameterize
15      slug_candidate = itr > 1 ? "#{title_slug}-#{itr}" : title_slug
16      break self.slug = slug_candidate unless Task.exists?(slug: slug_candidate)
17      itr += 1
18    end
19  end
20end

The set_slug method is setting slug attribute as a parameterized version of the title. When doing so, if the same slug already exists in the database, we use an iterator and append it to the end of the slug, and loop until we generate a unique slug.

parameterize is a ruby method, part of ActiveSupport inflectors, which replaces special characters in a string so that it may be used as part of a 'pretty' URL. To read more about it you can refer the official documentation for parameterize.

Example 1:

  • Oliver creates a task with title buy milk.
  • We generate a slug buy-milk and stores it to the task.

Example 2:

  • Oliver creates another task with title buy cheese.
  • But let's assume that the slug named buy-cheese already exists.
  • Thus we use a iterator starting from 2 and append to slug.
  • Here the unique slug would be something like buy-cheese-2.

If you are wondering what is unless keyword, it is the exact opposite of if statement.

Making slug immutable

Although we set slug attribute to a parameterized version of the title, we don't need to ensure that the slug gets updated during update of the title corresponding to that Task. We need to keep slug immutable, meaning once set, it doesn't change.

To make it immutable, we just need to add a custom validation like so:

1class Task < ApplicationRecord
2  belongs_to :user
3
4  validates :title, presence: true, length: { maximum: 50 }
5  validates :slug, uniqueness: true
6  validate :slug_not_changed
7
8  private
9
10  def set_slug
11    itr = 1
12    loop do
13      title_slug = title.parameterize
14      slug_candidate = itr > 1 ? "#{title_slug}-#{itr}" : title_slug
15      break self.slug = slug_candidate unless Task.exists?(slug: slug_candidate)
16      itr += 1
17    end
18  end
19
20  def slug_not_changed
21    if slug_changed? && self.persisted?
22      errors.add(:slug, 'is immutable!')
23    end
24  end
25end

The slug_not_changed method is checking if the slug has changed and if it has changed we are adding a validation error to slug attribute.

We make use of column_name_changed? attribute method provided by ActiveModel::Dirty module. It provides a way to track changes in our object in the same way as Active Record does.

self is a ruby keyword that gives you access to the current object.

persisted? is a ruby method, part of ActiveRecord::Persistence, which returns true if the record is persisted, i.e. it's not a new record and it was not destroyed, otherwise returns false.

So, here slug_changed? && self.persisted? is ensuring that slug has changed as well as persisted.

errors is an instance of ActiveModel::Errors, which provides error related functionalities, which we can include in our object for handling error messages and interacting with ActionView::Helpers. add is a ruby method, part of ActiveModel::Errors class, which adds a new error of type on a particular attribute. More than one error can be added to the same attribute.

Here errors.add(:slug, 'is immutable!') adds an error message is immutable! on slug attribute.

The official Rails guide has a section on ActiveModel::Dirty, ActiveRecord::Persistence & ActiveModel::Errors. Please read the official documentation if you want to dive deeper into these concepts.

Handling Race Conditions

A race condition is an undesirable situation that occurs when a device or system attempts to perform two or more operations at the same time, but because of the nature of the device or system, the operations must be executed in the proper sequence to be done correctly.

Our application's task API is still prone to race condition even after we have added validates :slug, uniqueness: true in the Task model. This is because the uniqueness helper method validates that the attribute's value is unique only just right before the object gets saved. It first queries the table, using the SELECT statement, and then attempts to insert a row if no matching records are found.

That leaves a timing gap between the SELECT and the INSERT statements that can cause problems in high throughput applications. It’s possible that user may double-click a submit button and duplicate a HTTP request or it may happen that two different database connections create two records with the same value for a column that we intended to be unique.

If the timing is perfect, then the SELECT statement would return null, meaning no records found, and based on that, try to perform INSERT operation with the current value, without realising there already exists a record with same value. In the end, we will end up with two rows having the same value in database, thus failing the uniqueness validation.

To prevent this, we need to add a unique constraint at the database level. After that we could monitor for failure scenarios by catching the ActiveRecord::RecordNotUnique exception when trying to manipulate the slug field. This strategy works as a very strong defence against race conditions.

We had already added the unique index with this line add_index :tasks, :slug, unique: true in AddSlugToTask migration in the section to generate a slug. It will create a uniqueness constraint at the database level. Now even if two or more requests tries to INSERT the same value of slug in the Task table, it will throw ActiveRecord::RecordNotUnique exception. We can catch that exception and send back our custom error message.

Moving response messages to i18n en.locales

i18n is "Ruby internationalization and localization solution". It provides support for English and similar languages by default. To use other languages, we can set it in our config/application.rb.

For eg, in the previous chapter, we manually hard coded and returned a json response with the notice as "Task was successfully created". So what if we needed the same response to be used multiple times in our app? Instead of hardcoding this string response message each time, we can use en locales to accommodate our string messages and reuse them the way we access variables. This allows for modularizing and reusing the messages.

Let's create en.yml file in config/locales and add the following code:

1en:
2  task:
3    slug:
4      immutable: "is immutable!"

Here, we have created our data in a json like format called YAML. It's just another markup language (Yet Another Markup Language). Whenever task.slug.immutable is translated, it refers to the string "is immutable!". Now we can access it in our tasks_controller by using t(). t() is an alias for translate, which is a TranslationHelper. Let's use that to add our custom error message to errors, like so:

1class Task < ApplicationRecord
2  belongs_to :user
3
4  validates :title, presence: true, length: { maximum: 50 }
5  validates :slug, uniqueness: true
6  validate :slug_not_changed
7
8  private
9
10  def set_slug
11    itr = 1
12    loop do
13      title_slug = title.parameterize
14      slug_candidate = itr > 1 ? "#{title_slug}-#{itr}" : title_slug
15      break self.slug = slug_candidate unless Task.exists?(slug: slug_candidate)
16      itr += 1
17    end
18  end
19
20  def slug_not_changed
21    if slug_changed? && self.persisted?
22      errors.add(:slug, t('task.slug.immutable'))
23    end
24  end
25end

Now, let's commit the changes:

1git add -A
2git commit -m "Added slug to task"
⌘F
    to navigateEnterto select Escto close
    Older
    Newer