Coin-toss.org / Simple Phoenix application - Part 3

Summary

The purpose of this post is to document the creation process of the Coin-toss.org website using the Phoenix Web application framework and the Elixir programming languages. Part 3 steps include:

  • Adding Changeset modifier functions to the Toss model
  • Querying similar tosses from the Toss model

Technology used:

  • Elixir 1.2
  • Phoenix 1.1.1
  • Postgres 9.4

Adding Changeset modifier functions to the Toss model

In the last part of the application we saved a new model to the database by using the Repo.insert!(changeset) function in the create/2 function of our toss_controller.ex. This, however, only saves the heads, tails, and ip attributes to the database. What is missing are the hash and result attributes. These should be generated by the Toss module automatically as they need to be run on every changeset insert. Let us create two functions that update an existing changeset as it is created and before it is inserted. Modify the toss.ex file as follows:

def changeset(model, params \\ :empty) do
  model
  |> cast(params, @required_fields, @optional_fields)
  |> decide_result
  |> hash_params
end

defp decide_result(changeset) do
  put_change(changeset, :result, flip)
end

defp flip do
  :random.seed(:erlang.timestamp)
  if :random.uniform >= 0.5, do: 0, else: 1
end

defp hash_params(changeset) do
  hash = [changeset.params["heads"], changeset.params["tails"]]
    |> Enum.sort
    |> Enum.join(":")
    |> String.downcase
    |> String.replace(" ", "")
  put_change(changeset, :hash, hash)
end

The changeset gets passed to the decide_result/1 function which will get a random number between 0 and 1 and then insert it into the result attribute. It then gets passed to the hash_params/1 function which normalizes the heads and tails attributes by putting them in order, downcasing them, and removing spaces. There are obviously many ways to game the hash, but this is supposed to be more for exercise purposes than anything else. Next we need to add our test to make sure it works. Add the following to test\models\toss_test.exs:

test "changeset/2 adds a result to the changeset" do
  changeset = Toss.changeset(%Toss{}, @valid_attrs)
  assert Ecto.Changeset.get_field(changeset, :result)
  assert [0,1] |> Enum.member?(Ecto.Changeset.get_field(changeset, :result))
end

test "changeset/2 adds a hash of heads and tails to the changeset" do
  changeset = Toss.changeset(%Toss{}, @valid_attrs)
  assert Ecto.Changeset.get_field(changeset, :hash)
  assert Ecto.Changeset.get_field(changeset, :hash) == "abcd:efgh"
end

and mix.test yields:

$ mix test
........................

Finished in 0.3 seconds (0.2s on load, 0.07s on tests)
24 tests, 0 failures

Now we are certain that the models gets a result and a hash before it is inserted.

Querying similar tosses from the Toss model

As part of our requirement we want to query similar Toss models. “Similar” is determined by a match in hashes and created within a certain time frame. In Ecto you can create composable query functions that can be placed in the model and then called from a controller. Let us add two queries to the Toss module, one for matching hashes, and one for time frames:

def created_in_last_n_days(query, days) do
  age = 0 - days
  from t in query,
    where: t.inserted_at > datetime_add(^Ecto.DateTime.utc, ^age, "day")
end

def with_specific_hash(query, hash) do
  from t in query,
    where: t.hash == ^hash
end

We can also test these in the test/toss_test.exs file:

test "created_in_last_n_days/2 creates an Ecto.Query" do
  assert Toss.created_in_last_n_days(Toss, 7).__struct__ == Ecto.Query
end

test "with_specific_hash/2 creates an Ecto.Query" do
  assert Toss.with_specific_hash(Toss, "").__struct__ == Ecto.Query
end

test "created_in_last_n_days/2 and with_specific_hash/2 are composable" do
  toss = Toss.changeset(%Toss{}, @valid_attrs) |> Repo.insert!
  assert Toss
    |> Toss.created_in_last_n_days(7)
    |> Toss.with_specific_hash(toss.hash)
    |> Repo.one!
end

Now we can modify our show/2 function in web/controllers/toss_controller.ex to include this functionality:

def show(conn, %{"id" => id}) do
  toss = Repo.get!(Toss, id)
  similar = Toss
    |> Toss.created_in_last_n_days(2)
    |> Toss.with_specific_hash(toss.hash)
    |> Repo.all
  render conn, "show.html", toss: toss, similar: similar
end

Now this function will also return the original Toss that we are querying against - we will want to show this in our view, but we need to keep it in mind for our test in test\controllers\toss_controller_test.exs which we can add now:

test "GET /toss/:id queries similar tosses", %{conn: conn} do
  _toss = Toss.changeset(%Toss{}, %{heads: "A", tails: "B", ip: "C"}) |> Repo.insert!
  toss = Toss.changeset(%Toss{}, %{heads: "A", tails: "B", ip: "C"}) |> Repo.insert!
  conn = get conn, toss_path(conn, :show, toss)
  assert conn.assigns[:similar] |> length == 2
end

test "GET /toss/:id ignores different tosses", %{conn: conn} do
  _toss = Toss.changeset(%Toss{}, %{heads: "E", tails: "F", ip: "C"}) |> Repo.insert!
  toss = Toss.changeset(%Toss{}, %{heads: "A", tails: "B", ip: "C"}) |> Repo.insert!
  conn = get conn, toss_path(conn, :show, toss)
  assert conn.assigns[:similar] |> length == 1
end

Conclusion

In this part we added some new functions that modify the changeset of the Toss module. We also used composable queries to query similar Toss models. In the next part we will implement the JSON API.