In part 8 of the url shortener adventure we migrate away from the file system and learn how to use Ecto and Postgres.
Part 8: Ecto
Today we write each shortened url to the file system and this helps us restore application state between restarts. While that solved the problem originally, most real Elixir applications store data in a database such as Postgres or MySQL.
We begin by adding a few dependencies to the mix.exs file that explicitly pull in ecto_sql, postgrex and phoenix_ecto.
defmodule Example.MixProject do
use Mix.Project
defp deps do
[
...
{:phoenix_ecto, "~> 4.0"},
{:ecto_sql, "~> 3.0"},
{:postgrex, ">= 0.0.0"}
]
end
end
Install these new dependencies using mix on the command line.
mix deps.get
Next add a new file named repo.ex that will tell Ecto about the adapter we are using for this project.
defmodule Example.Repo do
use Ecto.Repo,
otp_app: :example,
adapter: Ecto.Adapters.Postgres
end
Before we can use this Repo we need to update the application.ex file. Add an entry for `Example.Repo` to the child process list.
defmodule Example.Application do
use Application
def start(_type, _args) do
import Supervisor.Spec
children = [
supervisor(Example.Repo, []),
supervisor(ExampleWeb.Endpoint, []),
...
]
opts = [strategy: :one_for_one, name: Example.Supervisor]
Supervisor.start_link(children, opts)
end
end
Now we can add a schema that defines the link data structure we plan to store in the database. Add the file link.ex and include 2 fields along with a simple timestamp to capture `inserted_at` along with `updated_at` datetimes.
defmodule Example.Link do
use Ecto.Schema
schema "links" do
field :hash, :string
field :url, :string
timestamps()
end
end
Next we need to add some configuration to wire up our Ecto repository. Open the config.exs file and add a line to point `ecto_repos` at the Repo we created earlier.
use Mix.Config
config :example,
ecto_repos: [Example.Repo]
...
Now in the dev.exs file we need to configure the username, password, database, and hostname to connect with Postgres.
# Configure your database
config :example, Example.Repo,
username: "postgres",
password: "postgres",
database: "example_dev",
hostname: "localhost",
pool_size: 10
We now have enough code in place to run a few ecto commands with mix to see if we can create the database we've configured.
note: this step does assume you've got postgres installed and running
mix ecto.create
mix ecto.gen.migration create_links
Next open the create_links.exs migration file and fill out the `change` function to create our table with hash and url fields.
defmodule Example.Repo.Migrations.CreateLinks do
use Ecto.Migration
def change do
create table(:links) do
add :hash, :string, primary_key: true
add :url, :string
timestamps()
end
create unique_index(:links, [:url])
end
end
Now run that migration using mix from the command line.
mix ecto.migrate
Later we will run the tests to confirm this migration created the tables but a quick spot check with the `psql` command line tool will help us feel warm and fuzzy.
psql -h localhost -d example_dev -U postgres
If this connects run the command `\dt` to see what tables were created. You should see the `links` table we created in the migration above.
Now that we have a schema and database connectivity we need to write a module that will let us query with Ecto. Add a new file to the project named links.ex that will serve as the interface between Elixir and Postgres.
defmodule Example.Links do
import Ecto.Query, warn: false
alias Example.Repo
alias Example.Link
def all do
Repo.all(Link)
end
def create_link(attrs \\ %{}) do
Link
|> struct(attrs)
|> Repo.insert()
end
end
The module in our application that persists shortened links today is cache.ex. We've isolated the file system enough that this swap should be painless. First update `handle_call` to use the new `Links.all` function. We pull out the hash and url values and throw them into a Map. Next update `handle_cast` to use the `Links.create_link` function. We pass the hash and url arguments down using a Map that will later be transformed into a struct before Ecto does the insert.
defmodule Example.Cache do
use GenServer
alias Example.Links
...
@impl GenServer
def handle_call({:all}, _timeout, _state) do
state = for %Example.Link{hash: key, url: value} <- Links.all(), into: %{}, do: {key, value}
{:reply, state, state}
end
@impl GenServer
def handle_cast({:put, key, value}, state) do
Links.create_link(%{hash: key, url: value})
{:noreply, Map.put(state, key, value)}
end
end
To test this functionality we first need to swap out the file system helpers I wrote previously for the `DataCase` helper that ships with Phoenix.
defmodule CacheTest do
use ExampleWeb.DataCase, async: false
test "all returns state and put updates it" do
{:ok, _} = GenServer.start_link(Example.Cache, :ok)
assert Example.Cache.all(:cache) === %{}
Example.Cache.put(:cache, "x", "google.com")
assert Example.Cache.all(:cache) === %{"x" => "google.com"}
end
end
Update the test helper itself to include the Sandbox adapter.
ExUnit.start()
Ecto.Adapters.SQL.Sandbox.mode(Example.Repo, :manual)
Update the test.exs file to configure the username, password, database, and hostname to connect with Postgres.
# Configure your database
config :example, Example.Repo,
username: "postgres",
password: "postgres",
database: "example_test",
hostname: "localhost",
pool: Ecto.Adapters.SQL.Sandbox
Update the mix.exs file to include the aliases that setup and teardown our database between tests.
defmodule Example.MixProject do
use Mix.Project
def project do
[
app: :example,
...
aliases: aliases(),
deps: deps()
]
end
defp aliases do
[
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
"ecto.reset": ["ecto.drop", "ecto.setup"],
test: ["ecto.create --quiet", "ecto.migrate", "test"]
]
end
end
Now you can run the cache test from the command line with mix to see it work end to end.
mix test test/cache_test.exs
You can track my progress on github commit by commit. If you just want the code for this post checkout this commit.