Search
⌘K
    to navigateEnterto select Escto close

    Adding slug to task

    So far, we have added the ability to display the list of tasks in the front-end side. 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 needs to fetch these details from the database.

    The question is how would the database know which task details are being 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 job done, but it exposes the primary key of the task to anyone who views the network request or the URL. Here the value "1" in URL denotes the "id" of the task.

    Some details, like how many records we have in our database for a particular entity or what is the human readable ID of an entity in database, etc, shouldn't be exposed to the public. In the above above case, since we are using the id of a task in the URL, anyone who has access to the URL will be able to figure out the confidential information.

    To solve this problem we need to add a unique identifier to each task and it can be used to query details of a particular task via HTTP requests.

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

    What is a slug?

    A slug is the part of a URL that identifies a page in human-readable keywords.

    A well-defined slug can also help with SEO(search engine optimization).

    An 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, then it's 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.

    But that's not quite what we want!

    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", 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", 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", 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", 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 into the Task model:

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

    The uniqueness validation allows us to make sure that slug attribute's value is unique before it gets saved.

    Race conditions

    Let's see if adding a uniqueness validation in the model is good enough. Consider an application with a User model. User model consists of an email attribute which is supposed to be unique. Hence, a uniqueness validation is present for the email attribute inside the model.

    Now, two users, Oliver and Eve, are trying to signup for the application at the same time using the same email. This is a very likely scenario in a large scale system with millions of users.

    When the signup request from Oliver arrives, uniqueness helper method will validate that the attribute's value is unique only right before the object gets saved.

    It first queries the table to fetch any records that have the same attribute value for which an uniqueness validation is present, using the SELECT statement, and then attempts to insert a row if no matching records are found. This is also known as check and then act.

    This however, creates a timing gap between the SELECT and the INSERT statements that can cause problems.

    The problem will arise when in between the duration of the uniqueness check for Oliver's request and record creation, Eve makes a signup request with the same email.

    If the processing time of both requests overlap, then the SELECT statement would return null for Eve's request, meaning no records found, and based on that, try to perform INSERT operation, 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.

    This is called a Race condition.

    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, causing negative outcomes as opposed to the case when these processes are executed synchronously.

    Handling race conditions

    From the previous example we can conclude that validation at Rails level is not enough to ensure database integrity. Our application's task API is still prone to race condition even after we have added validates :slug, uniqueness: true in the Task model.

    To prevent this, we need to add a unique constraint at the database level. Once a unique constraint is added, database automatically creates a unique index to enforce the uniqueness requirement.

    When a database transaction is initiated, database examines the existing data for columns with a unique constraint in their respective indices to make sure all values are unique. If the values are not unique then database will return an error message.

    Therefore, after adding a unique constraint, we could monitor for failure scenarios by catching the ActiveRecord::RecordNotUnique exception and send back our custom error message. This strategy works as a very strong defence against race conditions.

    Adding a unique constraint in database

    Let's generate a migration to make slug unique at the database level.

    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 above 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

    And it will show the following output:

    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.

    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 handle it accordingly.

    It is important to note that, handling ActiveRecord::RecordNotUnique exception will be different for emails and slugs.

    For attributes whose value is provided by the user, like an email field, we should notify the user that the value entered is not unique and prompt them to enter a new unique value.

    Whereas for an attribute like slug, where the value is generated by application, we need to handle the exception and generate an unique value.

    We also need to change routes to make use of slug instead of id on task resource route. By default, Rails uses the :id identifier to denote the dynamic routes for a resource. For example, by default for a route with the pattern /tasks/:slug, the value of slug will be available to the controller using params[:id], which can cause confusion as we already have an id field in the Task model and since slug is actually not the id of a table.

    To avoid this confusion, Rails lets us override the default name by using param like so:

    1resources: tasks, param: :identifier_name

    After we override the param identifier name, we can use it in our controller action like so:

    1def show
    2  @task = Task.find_by(identifier_name: params[:identifier_name])
    3end

    Open /app/config/routes.rb and make the necessary change:

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

    Now the value for slug will be available to our controller actions in params[:slug].

    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")

    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, 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 sure slugs are 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 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  validates :title, presence: true, length: { maximum: 50 }
    3  validates :slug, uniqueness: true
    4
    5  private
    6
    7    def set_slug
    8      itr = 1
    9      loop do
    10        title_slug = title.parameterize
    11        slug_candidate = itr > 1 ? "#{title_slug}-#{itr}" : title_slug
    12        break self.slug = slug_candidate unless Task.exists?(slug: slug_candidate)
    13        itr += 1
    14      end
    15    end
    16end

    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 an 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 you 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 the unless keyword, it is the exact opposite of an if statement. It's same as saying if !condition.

    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 SeedSlugValueForExistingTasks < 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.

    You shouldn't use the send method unless it is really required. Using send might be a good choice if you have to call a private or protected 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", 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 database 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 specifically the before_create callback?

    The main reason for using the before_create callback 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 the after_commit callback then? This callback should only be used when we queue a background job or when telling another process about a change that we 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  validates :title, presence: true, length: { maximum: 50 }
    3  validates :slug, uniqueness: true
    4
    5  before_create :set_slug
    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

    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 shouldn't ever change.

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

    1class Task < ApplicationRecord
    2  validates :title, presence: true, length: { maximum: 50 }
    3  validates :slug, uniqueness: true
    4  validate :slug_not_changed
    5
    6  before_create :set_slug
    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 then we are adding a validation error to the 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. In simpler terms, if we need to know if a particular database column has changed in database level, then we can make use of these methods.

    self is a Ruby keyword that gives you access to the current object. Here, it will be the current task.

    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, and 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 the 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.

    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 key 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  validates :title, presence: true, length: { maximum: 50 }
    3  validates :slug, uniqueness: true
    4  validate :slug_not_changed
    5
    6  before_create :set_slug
    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

    Phew! That was a lot of good content. But still we can add some finesse to the overall process, which we will do in the next chapter.

    Now, let's commit these changes:

    1git add -A
    2git commit -m "Added slug to task"
    Previous
    Next