Learn Ruby on Rails Book

Adding slug to task

So far we have built displaying a list of tasks in our frontend. Next task would be clicking on one of the tasks and showing more details about that task.

To show more details about a task, the application need to fetch these details from the database.

The question is how would the database know which task details are requested for. The most common solution to this problem is having a URL which looks like this http:localhost:3000/tasks/1. This will get the work done but the users will also know that the primary key of the task 1.

If the number of tasks grow to hundreds of thousands then the competitors would also know how many tasks we have in our database because if a user creates a new task then that user will get the latest ID from the database.

To solve this problem We need to add a unique identifier to each task and to send the unique identifier along with the GET request to get task details.

These unique identifiers are typically called "slug". In this chapter we will learn how to add slugs for the tasks.

What is a slug

Good slug can also help with SEO(search engine optimization).

A URL like https://example.com/articles/357 is not very SEO friendly. However if the URL looks like https://example.com/articles/deploying-nextjs-application-to-netlify is very SEO friendly.

Rails, by default, uses integer based IDs in URLs to access resources. For example Rails would create /tasks/1 out of the box.

Generating a slug to use in the URL

The slug generation process depends on the business logic of the project. For instance, the slugs could be automatically created whenever a new task is added. Or the admins might need to add it manually.

In any case, the slug needs to be stored in the database along with the other attributes.

Let's generate a migration to add slug attribute in our tasks table:

1bundle exec rails generate migration AddSlugToTask

Add the following lines to the generated migration file:

1class AddSlugToTask < ActiveRecord::Migration[6.1]
2  def change
3    add_column :tasks, :slug, :string
4  end
5end

It will add a column named slug in our tasks table once we apply this migration.

Run the following command to apply the migration:

1bundle exec rails db:migrate

Unique indexes and why we use them

An index is a database structure which stores the values of a column in a specific order. It contains keys or records built from a column of the corresponding table.

When you query a record, the database goes to that index first and finds a reference of the record and then retrieves the corresponding table records.

It is relatively quicker to retrieve an index from the database than to retrieve a record since indexes are ordered. This can speed up the query process.

Since a slug is a unique identifier for a task it makes sense to add a unique index for the slug column.

Before we do so, let's consider a hypothetical case where slugs are not unique. We mean hypothetical because in a database with proper constraints, such records will not exist.

Let's use the Rails console to create a new task with a slug:

1irb(main):001:0> Task.create(title: "Buy groceries", user_id: 1, creator_id: 1, slug: "buy-groceries")

Running the above command will give the following output:

1=> #<Task id: 1, title: "Buy groceries", created_at: "2021-07-01 00:37:11", updated_at: "2021-07-01 00:37:11", user_id: 1, creator_id: 1, progress: "pending", status: "unstarred", slug: "buy-groceries">

Let's create another task with the same slug as the last task:

1irb(main):002:0> Task.create(title: "Pay phone bill", user_id: 1, creator_id: 1, slug: "buy-groceries")

Running the above command will give the following output:

1=> #<Task id: 2, title: "Pay phone bill", created_at: "2021-07-01 00:49:37", updated_at: "2021-07-01 00:49:37", user_id: 1, creator_id: 1, progress: "pending", status: "unstarred", slug: "buy-groceries">

We have successfully created two different tasks in our database with the same slug. But slugs are supposed to be unique since each slug is meant to be a unique identifier for a task.

Having duplicate slugs defies the whole purpose of having a slug and that is why we should have constraints in our application to prevent such records from existing in our database.

Let's fix this by adding 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 before it gets saved.

As we have seen before that validation at Rails level is not enough. We also need to make slug unique at the database level. To do so, let's generate a migration:

1bundle exec rails generate migration AddUniqueIndexForSlug

Add the following lines of code to the generated migration file:

1class AddUniqueIndexForSlug < ActiveRecord::Migration[6.1]
2  def change
3    add_index :tasks, :slug, unique: true
4  end
5end

Adding a unique constraint on database level will also handle race conditions. We will be discussing about it in the handling race conditions section.

Do you think running the following migration will raise any errors? Take a moment to think it through from the perspective of the current state of your database.

Let's apply the migration and see what happens:

1bundle exec rails db:migrate

Running the above command will throw the following error:

1== 20210701005815 AddUniqueIndexForSlug: migrating ============================
2-- add_index(:tasks, :slug, {:unique=>true})
3rails aborted!
4StandardError: An error has occurred, this and all later migrations canceled:
5
6SQLite3::ConstraintException: UNIQUE constraint failed: tasks.slug
7
8Caused by:
9ActiveRecord::RecordNotUnique: SQLite3::ConstraintException: UNIQUE constraint failed: tasks.slug

Error occurred due to the fact that our database contains two different tasks with the same slug.

It is evident from the example that for a unique index to exist, the values of slugs should also be unique and the migration above will ensure the same.

To conclude, the above migration could not be applied as it would have created an inconsistency between the database constraints and the records present.

To fix this, we should delete the records that are causing this inconsistency.

In order to avoid duplicate slugs and slug inconsistencies, we can delete all the tasks from our database.

Run the following command in the Rails console:

1irb(main):003:0> Task.delete_all

Here we have used the delete_all method. But there are other methods like destroy_all which would have also got this job done.

We will be discussing more about these methods in the destroy vs delete section.

We have deleted the records that were causing the above error, let's run the following command to apply the migration:

1bundle exec rails db:migrate
1== 20210701005815 AddUniqueIndexForSlug: migrating ============================
2-- add_index(:tasks, :slug, {:unique=>true})
3   -> 0.0031s
4== 20210701005815 AddUniqueIndexForSlug: migrated (0.0032s) ===================

Above migration will add a unique index containing ordered values from the slug column in our tasks table.

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

Adding a null constraint for slug

Let us take another hypothetical example of a task which doesn't have any value in it's slug column.

In Rails console, run the following command to create such a task.

1task = Task.create(title: "Dummy task", user_id: 1, creator_id: 1)

Running the above command will give the following output:

1=> #<Task id: 1, title: "Dummy task", created_at: "2021-07-01 00:50:34", updated_at: "2021-07-01 00:50:34", user_id: 1, creator_id: 1, progress: "pending", status: "unstarred", slug: "nil">

We can see from the output of task creation command that the value of slug in newly created task record is nil.

This isn't correct however. Having a null value in the slug column again defeats the purpose of having a slug.

We should make sure that the value of slug shouldn't be null. Let's create another migration to make slugs not nullable:

1bundle exec rails generate migration MakeSlugNotNullable

Add the following lines of code to the generated migration file:

1class MakeSlugNotNullable < ActiveRecord::Migration[6.1]
2  def change
3    change_column_null :tasks, :slug, false
4  end
5end

Let's apply the migration now to migrate the changes to database:

1bundle exec rails db:migrate

Running the above migration will throw an error similar to the one in the following output:

1== 20210701012247 MakeSlugNotNullable: migrating ==============================
2-- change_column_null(:tasks, :slug, false)
3rails aborted!
4StandardError: An error has occurred, this and all later migrations canceled:
5
6SQLite3::ConstraintException: NOT NULL constraint failed: tasks.slug
7
8Caused by:
9ActiveRecord::NotNullViolation: SQLite3::ConstraintException: NOT NULL constraint failed: tasks.slug

An error has occurred because we have task records in our database which don't have any value in the slug column.

Here we are trying to apply a migration that will constraint the slug column from having a null value. If we want to successfully apply this migration, then there shouldn't be any records in our database which has a null slug column.

But the current state of our database is not in a favorable situation for applying this migration since we already have a record with a null slug column.

We can fix this by applying our migration after deleting all records in tasks table where the value of slug is null. But this isn't a good solution in an existing database which cannot be deleted, such as a production database.

We can also fix this by adding a migration to add slug for the existing tasks. Migration to to generate slug for the existing records in the database should be applied before the migration that changes column null for slug.

In this way we can also prevent the ConstraintException we encountered when we ran our migration.

Hence we should delete the MakeSlugNotNullable migration for now. We will add it once we have added slugs to the existing tasks in the database.

Run the following command to delete the migration:

1bundle exec rails destroy migration MakeSlugNotNullable

Before we move on to creating a new migration, we need a method which we can call in our migration to query task records and update their slugs.

Add the following lines of code to the Task model:

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

We have created a private method called set_slug which we can call for each task and set it's slug.

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 part of ActiveSupport, which replaces special characters in a string so that it may be used as part of a 'pretty' URL. To read more about it we can refer to the official documentation.

Let's consider two examples to get a better understanding of how set-slug method is working.

Example 1:

Oliver creates the task with title buy milk. We generate a slug buy-milk and store it into 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 an iterator starting from 2 and append it to the 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.

Now that we have added a method we can call inside our migration to set the slug, we can go ahead and create a migration to seed our database.

Run the following command to generate migration:

1bundle exec rails generate migration SeedSlugValueForExistingTasks

Add the following lines of code to the generated migration file:

1class SetSlugValueForExistingTasks < ActiveRecord::Migration[6.1]
2  def up
3    Task.find_each do |task|
4      task.send(:set_slug)
5      task.save(validate: false)
6    end
7  end
8
9  def down
10    Task.find_each do |task|
11      task.update(slug: nil)
12      task.save(validate: false)
13    end
14  end
15end

Notice that we have also used a down method along with the up method in the SeedSlugValueForExistingTasks migration.

We have done so because the changes in up are the forward changes we want to make in our database when we apply the migration, whereas the changes in down are the changes we want to take place when we revert or rollback the migration.

This ensures that upon rollback our database will go back to the state it was previously in before we applied the migration.

In both the methods, we are querying a list of tasks from the database and iterating over them to call the set_slug method on each task in order to set a unique slug.

There are a number of ways through which we can query records from our database.

We could have used Task.all to fetch all task records from the database but that would have been very inefficient as all method tries to instantiate all the objects at once.

In our case, using find_each method allows us to query records in batches, thereby increasing the efficiency.

Default batch_size is 1000 but that can be changed by specifying the batch_size in the following manner:

1Task.find_each(batch_size: 50)

Above query will fetch records in batches of 50.

Another important thing to note here is the use of send method. Calling send invokes any method identified by a symbol, passing it any arguments specified.

Since set_slug is a private method in our Task model, it is not accessible to the task objects we are calling the method on.

Hence we have used the send method to call the private set_slug method.

Apply the migration using the following command:

1bundle exec rails db:migrate

Let us check if seeding the database using a migration worked. To do so, run the following command:

1Task.find_by(title: "Dummy task")

Running the above command should give us the following output:

1#<Task id: 1, title: "Dummy task", created_at: "2021-07-01 00:50:34", updated_at: "2021-07-01 00:50:34", user_id: 1, creator_id: 1, progress: "pending", status: "unstarred", slug: "dummy-task">

We can see from the output that we have successfully seeded the slug column with valid values in existing task records.

Now we can generate a migration and apply it to make slugs not nullable:

1bundle exec rails generate migration MakeSlugNotNullable

Add the following lines of code to the generated migration file:

1class MakeSlugNotNullable < ActiveRecord::Migration[6.1]
2  def change
3    change_column_null :tasks, :slug, false
4  end
5end

change_column_null sets or removes the NOT NULL constraint on a column.

It takes three arguments. First argument is the table name, second argument is the name of the column we want to apply the constraint to and the last value is a boolean value.

Passing false in the above migration will set the constraint. In simpler words, it won't allow the value in slug column to be null.

Run the following command to migrate changes to database:

1bundle exec rails db:migrate

Now that we have applied all slug related migrations, let's add logic to create this slug automatically upon creation of a new task.

Creating a unique slug upon task 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.

Now let's add the before_create callback into our Task model:

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

before_create callback will invoke the set_slug method once for each task before it's creation.

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 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  before_create :set_slug
9
10  private
11
12  def set_slug
13    itr = 1
14    loop do
15      title_slug = title.parameterize
16      slug_candidate = itr > 1 ? "#{title_slug}-#{itr}" : title_slug
17      break self.slug = slug_candidate unless Task.exists?(slug: slug_candidate)
18      itr += 1
19    end
20  end
21
22  def slug_not_changed
23    if slug_changed? && self.persisted?
24      errors.add(:slug, 'is immutable!')
25    end
26  end
27end

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 and 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 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 have already added a unique index with this line add_index :tasks, :slug, unique: true in AddUniqueIndexForSlug migration in the unique indexes and why we use them section. 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 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.

In every controller, we can use t() method without including any additional modules since AbstractController::Translation is already included in ActionController::Base.

As you can see, ApplicationController (in app/controllers/application_controller.rb), inherits from ActionController::Base.

ApplicationController is the superclass of all of our controller classes, and thus all of our controllers can access the methods defined in ActionController::Base including the t() we have discussed earlier.

Unlike controllers, instances of ApplicationRecord (or more specifically, models) don't have reference to t() methods out of the box. We need to manually include the module ActionView::Helpers::TranslationHelper to use t() in our models.

Instead of including ActionView::Helpers::TranslationHelper in every model, let us include it in their common superclass ApplicationRecord.

Open app/models/application_record.rb and add the following line to it:

1class ApplicationRecord < ActiveRecord::Base
2  include ActionView::Helpers::TranslationHelper
3  self.abstract_class = true
4end

Now, let's replace our hardcoded error message with t() method call:

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  before_create :set_slug
9
10  private
11
12  def set_slug
13    itr = 1
14    loop do
15      title_slug = title.parameterize
16      slug_candidate = itr > 1 ? "#{title_slug}-#{itr}" : title_slug
17      break self.slug = slug_candidate unless Task.exists?(slug: slug_candidate)
18      itr += 1
19    end
20  end
21
22  def slug_not_changed
23    if slug_changed? && self.persisted?
24      errors.add(:slug, t('task.slug.immutable'))
25    end
26  end
27end

Now, let's commit the changes:

1git add -A
2git commit -m "Added slug to task"
⌘K
    to navigateEnterto select Escto close
    Previous
    Next