Skip to content

Commit

Permalink
Merge pull request #25 from dwyl/unique-constraints
Browse files Browse the repository at this point in the history
Unique Constraints
  • Loading branch information
nelsonic authored Dec 12, 2018
2 parents 57688fa + 7e72e55 commit 072b7c6
Show file tree
Hide file tree
Showing 10 changed files with 172 additions and 19 deletions.
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,21 @@ This module provides some helper functions to make it easy to insert and retriev
end
```

## Repo

Alog expects your `Repo` to belong to the same base module as the schema.
So if your schema is `MyApp.User`, or `MyApp.Accounts.User`, your Repo should be `MyApp.Repo`.
So if your schema is `MyApp.User`, or `MyApp.Accounts.User`, your Repo should be `MyApp.Repo`.

## Uniqueness

Due to the append only manner in which Alog stores data, it is not compatible with tables that have Unique Indexes applied to any of their columns. If you wish to use alog, you will have to remove these indexes.

For example, the following in a migration file would remove a unique index on the `email` column from the `users` table.

```
drop(unique_index(:users, :email))
```

See https://hexdocs.pm/ecto_sql/Ecto.Migration.html#content for more details.

If you want to ensure each entry in your database has a unique field, you can use the [`Ecto.Changeset.unique_constraint/3`](https://hexdocs.pm/ecto/Ecto.Changeset.html#unique_constraint/3) function as normal, and Alog will ensure there are no repeated fields, other than those of the same entry, returning an invalid changeset if there are.
86 changes: 72 additions & 14 deletions lib/alog.ex
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,16 @@ defmodule Alog do
|> User.insert()
"""
def insert(struct_or_changeset) do
struct_or_changeset
|> insert_entry_id()
|> @repo.insert()
case check_for_unique_index() do
:ok ->
struct_or_changeset
|> insert_entry_id()
|> apply_constraints()
|> @repo.insert()

{:error, msg} ->
raise msg
end
end

@doc """
Expand Down Expand Up @@ -161,17 +168,24 @@ defmodule Alog do
|> User.update()
"""
def update(%Ecto.Changeset{} = changeset) do
data =
changeset
|> Map.get(:data)
|> @repo.preload(__MODULE__.__schema__(:associations))
|> Map.put(:id, nil)
|> Map.put(:inserted_at, nil)
|> Map.put(:updated_at, nil)

changeset
|> Map.put(:data, data)
|> @repo.insert()
case check_for_unique_index() do
:ok ->
data =
changeset
|> Map.get(:data)
|> @repo.preload(__MODULE__.__schema__(:associations))
|> Map.put(:id, nil)
|> Map.put(:inserted_at, nil)
|> Map.put(:updated_at, nil)

changeset
|> apply_constraints()
|> Map.put(:data, data)
|> @repo.insert()

{:error, msg} ->
raise msg
end
end

def update(_) do
Expand Down Expand Up @@ -266,6 +280,22 @@ defmodule Alog do
@repo.preload(item, [{assoc, preload_query(assoc)}])
end

defp apply_constraints(%Ecto.Changeset{} = changeset) do
changeset
|> Map.get(:constraints)
|> Enum.reduce(changeset, fn con, acc ->
with :unique <- con.type,
change when not is_nil(change) <- Map.get(changeset.changes, con.field),
existing when not is_nil(existing) <- __MODULE__.get_by([{con.field, change}]) do
Ecto.Changeset.add_error(acc, con.field, Map.get(con, :error) |> elem(0))
else
_ -> acc
end
end)
end

defp apply_constraints(struct), do: struct

defp preload_map(assoc, owner) do
case assoc do
{k, v} ->
Expand Down Expand Up @@ -326,6 +356,34 @@ defmodule Alog do
end)
end

defp check_for_unique_index() do
table = __MODULE__.__schema__(:source)
"Elixir." <> module_name = unquote(__MODULE__) |> to_string()

case @repo.query(
"SELECT * FROM pg_indexes WHERE tablename = $1 and indexname NOT LIKE '%_pkey' AND indexdef LIKE 'CREATE UNIQUE INDEX%';",
[table]
) do
{:ok, %Postgrex.Result{columns: columns, rows: rows}} when rows != [] ->
unique_index =
rows
|> List.first()
|> Enum.zip(columns)
|> Enum.find(fn {_r, c} -> c == "indexname" end)
|> elem(0)

{:error,
"""
Unique index '#{unique_index}' found on table '#{table}'.
#{module_name} is not compatible with tables that have a unique index.
Please remove this index if you want to use #{module_name}.
"""}

_ ->
:ok
end
end

defoverridable Alog
end
end
Expand Down
2 changes: 1 addition & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ defmodule Alog.MixProject do
def project do
[
app: :alog,
version: "0.3.0",
version: "0.4.0",
elixir: "~> 1.7",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
Expand Down
2 changes: 1 addition & 1 deletion mix.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
%{
"connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"},
"db_connection": {:hex, :db_connection, "1.1.3", "89b30ca1ef0a3b469b1c779579590688561d586694a3ce8792985d4d7e575a61", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, repo: "hexpm", optional: true]}], "hexpm"},
"decimal": {:hex, :decimal, "1.5.0", "b0433a36d0e2430e3d50291b1c65f53c37d56f83665b43d79963684865beab68", [:mix], [], "hexpm"},
"decimal": {:hex, :decimal, "1.6.0", "bfd84d90ff966e1f5d4370bdd3943432d8f65f07d3bab48001aebd7030590dcc", [:mix], [], "hexpm"},
"ecto": {:hex, :ecto, "2.2.11", "4bb8f11718b72ba97a2696f65d247a379e739a0ecabf6a13ad1face79844791c", [:mix], [{:db_connection, "~> 1.1", [hex: :db_connection, repo: "hexpm", optional: true]}, {:decimal, "~> 1.2", [hex: :decimal, repo: "hexpm", optional: false]}, {:mariaex, "~> 0.8.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, repo: "hexpm", optional: true]}], "hexpm"},
"poolboy": {:hex, :poolboy, "1.5.1", "6b46163901cfd0a1b43d692657ed9d7e599853b3b21b95ae5ae0a777cf9b6ca8", [:rebar], [], "hexpm"},
"postgrex": {:hex, :postgrex, "0.13.5", "3d931aba29363e1443da167a4b12f06dcd171103c424de15e5f3fc2ba3e6d9c5", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 1.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm"},
Expand Down
15 changes: 15 additions & 0 deletions priv/repo/test_app/migrations/20181211161000_unique_index.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
defmodule Alog.Repo.Migrations.UniqueIndex do
use Ecto.Migration

def change do
create table(:unique) do
add(:name, :string)
add(:entry_id, :string)
add(:deleted, :boolean, default: false)

timestamps()
end

create(unique_index(:unique, :name))
end
end
10 changes: 10 additions & 0 deletions test/alog_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,14 @@ defmodule AlogTest do
end).()
end
end

describe "Not compatible with unique index" do
test "Throws error if unique index exists" do
assert_raise RuntimeError, fn ->
%Alog.TestApp.Unique{}
|> Alog.TestApp.Unique.changeset(%{name: "unique item"})
|> Alog.TestApp.Unique.insert()
end
end
end
end
33 changes: 33 additions & 0 deletions test/constraint_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
defmodule AlogTest.ConstraintTest do
use Alog.TestApp.DataCase

alias Alog.TestApp.{User, Helpers}

describe "apply_constraints/1:" do
test "returns error if not unique on insert" do
{:ok, user_1} = %User{} |> User.changeset(Helpers.user_1_params()) |> User.insert()

assert {:error, user_2} =
%User{}
|> User.changeset(
Helpers.user_2_params()
|> Map.merge(%{username: user_1.username})
)
|> User.insert()

assert user_2.errors == [username: {"has already been taken", []}]
end

test "returns error if not unique on update" do
{:ok, user_1} = %User{} |> User.changeset(Helpers.user_1_params()) |> User.insert()
{:ok, user_2} = %User{} |> User.changeset(Helpers.user_2_params()) |> User.insert()

assert {:error, user_2} =
user_2
|> User.changeset(%{username: user_1.username})
|> User.update()

assert user_2.errors == [username: {"has already been taken", []}]
end
end
end
20 changes: 20 additions & 0 deletions test/support/unique.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
defmodule Alog.TestApp.Unique do
use Ecto.Schema
use Alog
import Ecto.Changeset

schema "unique" do
field(:name, :string)
field(:entry_id, :string)
field(:deleted, :boolean, default: false)

timestamps()
end

@doc false
def changeset(unique, attrs) do
unique
|> cast(attrs, [:name])
|> validate_required([:name])
end
end
1 change: 1 addition & 0 deletions test/support/user.ex
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ defmodule Alog.TestApp.User do
user
|> cast(attrs, [:name, :username, :postcode, :deleted])
|> validate_required([:name, :username, :postcode])
|> unique_constraint(:username)
end

def user_and_item_changeset(user, attrs) do
Expand Down
4 changes: 2 additions & 2 deletions test/update_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ defmodule AlogTest.UpdateTest do
end

test "associations remain after update" do
{:ok, user, item} = Helpers.seed_data()
{:ok, user, _item} = Helpers.seed_data()

{:ok, updated_user} = user |> User.changeset(%{postcode: "W2 3EC"}) |> User.update()
{:ok, _updated_user} = user |> User.changeset(%{postcode: "W2 3EC"}) |> User.update()

assert User.get(user.entry_id) |> User.preload(:items) |> Map.get(:items) |> length == 1
end
Expand Down

0 comments on commit 072b7c6

Please sign in to comment.