Our URLs are ugly. We'd rather see slugs in the URL than IDs. To facilitate this, we'll introduce ecto_autoslug_field. Let's get started.

Project

We're starting with the dailydrip/firestorm repo tagged before this episode.

Let's add the dependency for ecto_autoslug_field:

vim mix.exs
defmodule FirestormWeb.Mixfile do
  # ...
  defp deps do
    [
      # ...
      {:ecto_autoslug_field, "~> 0.3.0"},
      # ...
    ]
  end
  # ...
end
mix deps.get

Next, let's write a quick test that asserts what our slugs will look like:

vim test/forums_test.exs
defmodule FirestormWeb.ForumsTest do
  # ...
  test "create_category/1 automatically generates a slug" do
    assert {:ok, %Category{} = category} = Forums.create_category(@create_category_attrs)
    assert category.slug == "some-title"
  end
  # ...
end

If we were to run this test it would fail, because categories don't have slugs yet. Let's add the field, for both Category and Thread:

mix ecto.gen.migration add_slug_to_categories_and_threads
defmodule FirestormWeb.Repo.Migrations.AddSlugToCategoriesAndThreads do
  use Ecto.Migration

  def change do
    alter table(:forums_categories) do
      add :slug, :string
    end
    alter table(:forums_threads) do
      add :slug, :string
    end
    create unique_index(:forums_categories, [:slug])
    create unique_index(:forums_threads, [:slug])
  end
end
mix ecto.migrate

Now we have a field that can be used to store our slugs, and we know they'll be unique. Let's use ecto_autoslug_field to generate these slugs from our title field:

vim lib/firestorm_web/web/forums/category.ex
defmodule FirestormWeb.Forums.Category do
  use Ecto.Schema

  alias FirestormWeb.Forums.Thread

  # We need to generate a module that knows how to generate our slug
  defmodule TitleSlug do
    use EctoAutoslugField.Slug, from: :title, to: :slug
  end

  schema "forums_categories" do
    field :title, :string
    # And we use that module as a type for our ecto field
    field :slug, TitleSlug.Type
    has_many :threads, Thread

    timestamps()
  end
end

We also need to use this in our Changeset function:

vim lib/firestorm_web/web/forums/forums.ex
defmodule FirestormWeb.Forums do
  # ...
  defp category_changeset(%Category{} = category, attrs) do
    category
    |> cast(attrs, [:title])
    |> validate_required([:title])
    |> Category.TitleSlug.maybe_generate_slug
    |> Category.TitleSlug.unique_constraint
  end
  # ...
end

Now we can run our tests, and they pass. What happens if someone makes another category with the same name though? Here's what I'd like:

  # ...
  test "create_category/1 automatically generates a slug" do
    assert {:ok, %Category{} = category} = Forums.create_category(@create_category_attrs)
    assert category.slug == "some-title"
    assert {:ok, %Category{} = category} = Forums.create_category(@create_category_attrs)
    assert category.slug == "some-title-1"
  end
  # ...

There's no way that should work yet though. If we run it, we get:

  1) test create_category/1 automatically generates a slug (FirestormWeb.ForumsTest)
     test/forums_test.exs:96
     match (=) failed
     code:  {:ok, %Category{} = category} = Forums.create_category(@create_category_attrs)
     right: {:error,
             #Ecto.Changeset<action: :insert,
              changes: %{slug: "some-title", title: "some title"},
              errors: [slug: {"has already been taken", []}],
              data: #FirestormWeb.Forums.Category<>, valid?: false>}
     stacktrace:
       test/forums_test.exs:99: (test)

We'd really like to have this hidden from us. Let's extend our TitleSlug module to handle this situation:

defmodule FirestormWeb.Forums.Category do
  # ...
  defmodule TitleSlug do
    use EctoAutoslugField.Slug, from: :title, to: :slug
    # We'll have to talk to the database to find a good unique value
    alias FirestormWeb.Repo
    alias FirestormWeb.Forums.Category
    import Ecto.Query

    # We'll get the base slug and then run that through a recursive function to
    # find an unused slug. If we used uuids instead this would be faster! But I
    # don't care to do that for now.
    def build_slug(sources) do
      base_slug = super(sources)
      get_unused_slug(base_slug, 0)
    end

    # We'll keep iterating the number until we have a unique slug
    def get_unused_slug(base_slug, number) do
      slug = get_slug(base_slug, number)
      if slug_used?(slug) do
        get_unused_slug(base_slug, number + 1)
      else
        slug
      end
    end

    # Here's a simple ecto query to find out if a slug is used or not
    def slug_used?(slug) do
      Category
      |> where(slug: ^slug)
      |> Repo.one
    end

    # And here we'll get the actual slug string based on our two pieces of data
    def get_slug(base_slug, 0), do: base_slug
    def get_slug(base_slug, number) do
      "#{base_slug}-#{number}"
    end
  end
  # ...
end

With this, we can run the test and it will pass. We want to use slugs for our threads that work the same way as well, so let's create a slugs directory inside of forums and create those two modules with some shared code. Since we're extending something that gets its power from use, we'll end up writing macros.

mkdir lib/firestorm_web/forums/slugs
vim lib/firestorm_web/forums/slugs/title_slug.ex

We'll mostly cut and paste in the existing TitleSlug module and rename it. However, we'll provide our own ability to use this that extends ecto_autoslug_field:

defmodule FirestormWeb.Forums.Slugs.TitleSlug do
  # In order to facilitate `use`ing the module, we need to define a macro called
  # `__using__`. We accept a single argument, the module that we want to use for
  # the uniqueness check. We'll use this later.
  defmacro __using__(module) do
    # We'll `quote` because we want to just emit normal code
    quote do
      use EctoAutoslugField.Slug, from: :title, to: :slug
      alias FirestormWeb.Repo
      import Ecto.Query

      def build_slug(sources) do
        base_slug = super(sources)
        get_unused_slug(base_slug, 0)
      end

      def get_unused_slug(base_slug, number) do
        slug = get_slug(base_slug, number)
        if slug_used?(slug) do
          get_unused_slug(base_slug, number + 1)
        else
          slug
        end
      end

      def slug_used?(slug) do
        # We'll `unquote` the module name we captured when we `use`d this module
        unquote(module)
        |> where(slug: ^slug)
        |> Repo.one
      end

      def get_slug(base_slug, 0), do: base_slug
      def get_slug(base_slug, number) do
        "#{base_slug}-#{number}"
      end
    end
  end
end

Now we can generate a CategoryTitleSlug module:

vim lib/firestorm_web/forums/slugs/category_title_slug.ex
defmodule FirestormWeb.Forums.Slugs.CategoryTitleSlug do
  use FirestormWeb.Forums.Slugs.TitleSlug, FirestormWeb.Forums.Category
end

And now we can we we can easily do the same thing for Threads, so let's add a ThreadTitleSlug and a test, and use these new modules inside of the Forums context:

defmodule FirestormWeb.Forums.Slugs.ThreadTitleSlug do
  use FirestormWeb.Forums.Slugs.TitleSlug, FirestormWeb.Forums.Thread
end

We'll update our schemas to use the new slug modules:

defmodule FirestormWeb.Forums.Category do
  # ...
  alias FirestormWeb.Forums.Slugs.CategoryTitleSlug

  schema "forums_categories" do
    # ...
    field :slug, CategoryTitleSlug.Type
    # ...
  end
end
defmodule FirestormWeb.Forums.Thread do
  # ...
  alias FirestormWeb.Forums.Slugs.ThreadTitleSlug

  schema "forums_threads" do
    # ...
    field :slug, ThreadTitleSlug.Type
    # ...
  end
end

And update the changesets to use them as well:

defmodule FirestormWeb.Forums do
  # ...
  defp category_changeset(%Category{} = category, attrs) do
    alias FirestormWeb.Forums.Slugs.CategoryTitleSlug
    category
    |> cast(attrs, [:title])
    |> validate_required([:title])
    |> CategoryTitleSlug.maybe_generate_slug
    |> CategoryTitleSlug.unique_constraint
  end
  # ...
  defp thread_changeset(%Thread{} = thread, attrs) do
    alias FirestormWeb.Forums.Slugs.ThreadTitleSlug
    thread
    |> cast(attrs, [:title, :category_id])
    |> validate_required([:title, :category_id])
    |> ThreadTitleSlug.maybe_generate_slug
    |> ThreadTitleSlug.unique_constraint
  end
  # ...
end

Now if we add a test for threads, we get slugs as well automatically!

defmodule FirestormWeb.ForumsTest do
  # ...
    # ...
    test "create_thread/2 automatically generates a slug", %{category: category, user: user} do
      assert {:ok, %Thread{} = thread} = Forums.create_thread(category, user, @create_thread_attrs)
      assert thread.slug == "some-title"
      assert {:ok, %Thread{} = thread} = Forums.create_thread(category, user, @create_thread_attrs)
      assert thread.slug == "some-title-1"
    end
    # ...
  # ...
end

Summary

In today's episode we saw how we can use ecto_autoslug_field to generate slugs for our models based on existing fields. It does more than I've shown here, so feel free to check out the docs. We also extended it to check our database to guarantee uniqueness of our slugs. I hope you enjoyed it. See you soon!

Resources