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!
Comments for Using Slugs in Ecto