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 Thread s, 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