diff --git a/lib/alog.ex b/lib/alog.ex index b343273..5ec306e 100644 --- a/lib/alog.ex +++ b/lib/alog.ex @@ -38,13 +38,14 @@ defmodule Alog do field(:entry_id, :string) """ - @callback insert(map()) :: {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()} + @callback insert(Ecto.Schema.t() | Ecto.Changeset.t()) :: + {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()} @callback get(String.t()) :: Ecto.Schema.t() | nil | no_return() @callback get_by(Keyword.t() | map()) :: Ecto.Schema.t() | nil | no_return() - @callback update(Ecto.Schema.t(), map()) :: - {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()} + @callback update(Ecto.Changeset.t()) :: {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()} @callback get_history(Ecto.Schema.t()) :: [Ecto.Schema.t()] | no_return() - @callback delete(Ecto.Schema.t()) :: {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()} + @callback delete(Ecto.Schema.t() | Ecto.Changeset.t()) :: + {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()} @callback preload(Ecto.Schema.t() | list(Ecto.Schema.t()), atom() | list()) :: Ecto.Schema.t() | list(Ecto.Schema.t()) @@ -85,15 +86,23 @@ defmodule Alog do end @doc """ - Applies a schema's changeset function and inserts it into the database. + Inserts a struct made with a schema or a changeset into the database. Adds an entry id to link it to future updates of the item. - User.insert(attributes) + If `cast_assoc` has been used on the changeset before passing it to this function, + any nested associations will also be given an `entry_id` before they are + inserted into the database. + + %User{name: "username", age: "25"} + |> User.insert() + + %User{} + |> User.changeset(%{name: "username", age: "25"}) + |> User.insert() """ - def insert(attrs) do - %__MODULE__{} + def insert(struct_or_changeset) do + struct_or_changeset |> insert_entry_id() - |> __MODULE__.changeset(attrs) |> @repo.insert() end @@ -145,20 +154,30 @@ defmodule Alog do Updates an item in the database. Copies the current row, updates the relevant fields and appends it to the database table. + Requires a changeset to be given. User.get("5ds4fg31-a7f1-2hd8-x56a-d4s3g7ded1vv2") - |> User.update(%{age: 44}) + |> User.changeset(%{age: 44}) + |> User.update() """ - def update(%__MODULE__{} = item, attrs) do - item - |> @repo.preload(__MODULE__.__schema__(:associations)) - |> Map.put(:id, nil) - |> Map.put(:inserted_at, nil) - |> Map.put(:updated_at, nil) - |> __MODULE__.changeset(attrs) + def update(%Ecto.Changeset{} = changeset) do + data = + changeset + |> Map.get(:data) + |> Map.put(:id, nil) + |> Map.put(:inserted_at, nil) + |> Map.put(:updated_at, nil) + |> @repo.preload(__MODULE__.__schema__(:associations)) + + changeset + |> Map.put(:data, data) |> @repo.insert() end + def update(_) do + raise ArgumentError, "The argument provided to update/1 must be an Ecto.Changeset" + end + @doc """ Gets the full history of an item in the database. @@ -199,15 +218,21 @@ defmodule Alog do User.get("5ds4fg31-a7f1-2hd8-x56a-d4s3g7ded1vv2") |> User.delete() + + User.get("5ds4fg31-a7f1-2hd8-x56a-d4s3g7ded1vv2") + |> User.changeset(%{}) + |> User.delete() """ - def delete(item) do - item - |> @repo.preload(__MODULE__.__schema__(:associations)) - |> Map.put(:id, nil) - |> Map.put(:inserted_at, nil) - |> Map.put(:updated_at, nil) - |> __MODULE__.changeset(%{deleted: true}) - |> @repo.insert() + def delete(%Ecto.Changeset{} = changeset) do + changeset + |> Ecto.Changeset.put_change(:deleted, true) + |> update() + end + + def delete(%__MODULE__{} = entry) do + entry + |> Ecto.Changeset.cast(%{deleted: true}, [:deleted]) + |> update() end @doc """ @@ -264,13 +289,43 @@ defmodule Alog do from(m in subquery(sub), where: not m.deleted, select: m) end - defp insert_entry_id(entry) do + defp insert_entry_id(%Ecto.Changeset{} = entry) do + with {:ok, nil} <- Map.fetch(entry.data, :entry_id), + nil <- get_change(entry, :entry_id) do + entry + |> put_change(:entry_id, Ecto.UUID.generate()) + |> insert_nested_entry_ids() + else + _ -> + entry + end + end + + defp insert_entry_id(%__MODULE__{} = entry) do case Map.fetch(entry, :entry_id) do {:ok, nil} -> %{entry | entry_id: Ecto.UUID.generate()} _ -> entry end end + defp insert_nested_entry_ids(changeset) do + assocs = changeset.data.__struct__.__schema__(:associations) + + Enum.reduce(changeset.changes, changeset, fn {k, v}, acc -> + if k in assocs do + assoc = + case v do + l when is_list(l) -> Enum.map(l, &insert_entry_id/1) + item -> insert_entry_id(item) + end + + Ecto.Changeset.put_change(acc, k, assoc) + else + acc + end + end) + end + defoverridable Alog end end diff --git a/mix.exs b/mix.exs index 632a242..33138a3 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Alog.MixProject do def project do [ app: :alog, - version: "0.1.0", + version: "0.3.0", elixir: "~> 1.7", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/test/all_test.exs b/test/all_test.exs new file mode 100644 index 0000000..6b89cdf --- /dev/null +++ b/test/all_test.exs @@ -0,0 +1,22 @@ +defmodule AlogTest.AllTest do + use Alog.TestApp.DataCase + + alias Alog.TestApp.{User, Helpers} + + describe "all/0:" do + test "succeeds" do + {:ok, _} = %User{} |> User.changeset(Helpers.user_1_params()) |> User.insert() + {:ok, _} = %User{} |> User.changeset(Helpers.user_2_params()) |> User.insert() + + assert length(User.all()) == 2 + end + + test "does not include old items" do + {:ok, user} = %User{} |> User.changeset(Helpers.user_1_params()) |> User.insert() + {:ok, _} = %User{} |> User.changeset(Helpers.user_2_params()) |> User.insert() + {:ok, _} = user |> User.changeset(%{postcode: "W2 3EC"}) |> User.update() + + assert length(User.all()) == 2 + end + end +end diff --git a/test/alog_test.exs b/test/alog_test.exs index 3c1a58f..3312e9b 100644 --- a/test/alog_test.exs +++ b/test/alog_test.exs @@ -2,104 +2,6 @@ defmodule AlogTest do use Alog.TestApp.DataCase doctest Alog - alias Alog.TestApp.{User, Item, Helpers} - - describe "insert/1:" do - test "succeeds" do - assert {:ok, user} = - User.insert(%{name: "Thor", username: "gdofthndr12", postcode: "E2 0SY"}) - end - - test "validates required fields" do - {:error, changeset} = User.insert(%{name: "Thor"}) - - assert length(changeset.errors) > 0 - end - - test "inserted user is available" do - {:ok, user} = User.insert(%{name: "Thor", username: "gdofthndr12", postcode: "E2 0SY"}) - - assert User.get(user.entry_id) == user - end - end - - describe "update/2:" do - test "succeeds" do - {:ok, user} = User.insert(%{name: "Thor", username: "gdofthndr12", postcode: "E2 0SY"}) - assert {:ok, updated_user} = User.update(user, %{postcode: "W2 3EC"}) - end - - test "'get' returns most recently updated item" do - {:ok, user} = User.insert(%{name: "Thor", username: "gdofthndr12", postcode: "E2 0SY"}) - {:ok, updated_user} = User.update(user, %{postcode: "W2 3EC"}) - - assert User.get(user.entry_id) |> User.preload(:items) == updated_user - end - end - - describe "get_history/1:" do - test "gets all items" do - {:ok, user} = User.insert(%{name: "Thor", username: "gdofthndr12", postcode: "E2 0SY"}) - {:ok, updated_user} = User.update(user, %{postcode: "W2 3EC"}) - - assert length(User.get_history(updated_user)) == 2 - end - end - - describe "all/0:" do - test "succeeds" do - {:ok, _} = User.insert(%{name: "Thor", username: "gdofthndr12", postcode: "E2 0SY"}) - {:ok, _} = User.insert(%{name: "Loki", username: "mschfmkr", postcode: "E1 6DR"}) - - assert length(User.all()) == 2 - end - - test "does not include old items" do - {:ok, user} = User.insert(%{name: "Thor", username: "gdofthndr12", postcode: "E2 0SY"}) - {:ok, _} = User.insert(%{name: "Loki", username: "mschfmkr", postcode: "E1 6DR"}) - {:ok, _} = User.update(user, %{postcode: "W2 3EC"}) - - assert length(User.all()) == 2 - end - end - - describe "delete/1:" do - test "succeeds" do - {:ok, user} = User.insert(%{name: "Thor", username: "gdofthndr12", postcode: "E2 0SY"}) - assert {:ok, _} = User.delete(user) - end - - test "deleted items are not retrieved with 'get'" do - {:ok, user} = User.insert(%{name: "Thor", username: "gdofthndr12", postcode: "E2 0SY"}) - {:ok, _} = User.delete(user) - - assert User.get(user.entry_id) == nil - end - - test "deleted items are not retrieved with 'all'" do - {:ok, user} = User.insert(%{name: "Thor", username: "gdofthndr12", postcode: "E2 0SY"}) - {:ok, _} = User.delete(user) - - assert length(User.all()) == 0 - end - end - - describe "get_by/2:" do - test "only returns one result" do - {:ok, user} = User.insert(%{name: "Thor", username: "gdofthndr12", postcode: "E2 0SY"}) - {:ok, user_2} = User.insert(%{name: "Loki", username: "mschfmkr", postcode: "E2 0SY"}) - - assert User.get_by(postcode: "E2 0SY") == user_2 - end - - test "works with multiple clauses" do - {:ok, user} = User.insert(%{name: "Thor", username: "gdofthndr12", postcode: "E2 0SY"}) - {:ok, user_2} = User.insert(%{name: "Loki", username: "mschfmkr", postcode: "E2 0SY"}) - - assert User.get_by(postcode: "E2 0SY", name: "Thor") == user - end - end - describe "required fields" do test "schema without delete field raises error" do assert_raise RuntimeError, fn -> @@ -159,76 +61,4 @@ defmodule AlogTest do end).() end end - - describe "preload/2:" do - test "preloads many_to_many associations" do - {:ok, _, item} = Helpers.seed_data() - - # item types are not loaded by default - assert_raise ArgumentError, fn -> - item.entry_id - |> Item.get() - |> Map.get(:item_types) - |> length() - end - - assert item.entry_id - |> Item.get() - |> Item.preload(:item_types) - |> Map.get(:item_types) - |> length() == 1 - end - - test "preloads one_to_many associations" do - {:ok, user, _} = Helpers.seed_data() - - # items are not loaded by default - assert_raise ArgumentError, fn -> - user.entry_id - |> User.get() - |> Map.get(:items) - |> length() - end - - assert user.entry_id - |> User.get() - |> User.preload(:items) - |> Map.get(:items) - |> length() == 1 - end - - test "preloads nested associations" do - {:ok, user, item} = Helpers.seed_data() - - # item_types are not loaded by default - assert_raise ArgumentError, fn -> - item.entry_id - |> Item.get() - |> Map.get(:item_types) - |> length() - end - - assert user.entry_id - |> User.get() - |> User.preload(items: [:item_types]) - |> Map.get(:items) - |> List.first() - |> Map.get(:item_types) - |> length() == 1 - end - - test "preloads two level deep nested associations" do - {:ok, user, _} = Helpers.seed_data() - - assert user.entry_id - |> User.get() - |> User.preload(items: [item_types: [:items]]) - |> Map.get(:items) - |> List.first() - |> Map.get(:item_types) - |> List.first() - |> Map.get(:items) - |> length() == 2 - end - end end diff --git a/test/delete_test.exs b/test/delete_test.exs new file mode 100644 index 0000000..c4162bd --- /dev/null +++ b/test/delete_test.exs @@ -0,0 +1,47 @@ +defmodule AlogTest.DeleteTest do + use Alog.TestApp.DataCase + + alias Alog.TestApp.{User, Helpers} + + describe "delete/1:" do + test "succeeds" do + {:ok, user} = %User{} |> User.changeset(Helpers.user_1_params()) |> User.insert() + assert {:ok, _} = User.delete(user) + end + + test "deleted items are not retrieved with 'get'" do + {:ok, user} = %User{} |> User.changeset(Helpers.user_1_params()) |> User.insert() + {:ok, _} = User.delete(user) + + assert User.get(user.entry_id) == nil + end + + test "deleted items are not retrieved with 'all'" do + {:ok, user} = %User{} |> User.changeset(Helpers.user_1_params()) |> User.insert() + {:ok, _} = User.delete(user) + + assert length(User.all()) == 0 + end + end + + describe "delete/1 - with changeset:" do + test "succeeds" do + {:ok, user} = %User{} |> User.changeset(Helpers.user_1_params()) |> User.insert() + assert {:ok, _} = user |> User.changeset(%{}) |> User.delete() + end + + test "deleted items are not retrieved with 'get'" do + {:ok, user} = %User{} |> User.changeset(Helpers.user_1_params()) |> User.insert() + {:ok, _} = user |> User.changeset(%{}) |> User.delete() + + assert User.get(user.entry_id) == nil + end + + test "deleted items are not retrieved with 'all'" do + {:ok, user} = %User{} |> User.changeset(Helpers.user_1_params()) |> User.insert() + {:ok, _} = user |> User.changeset(%{}) |> User.delete() + + assert length(User.all()) == 0 + end + end +end diff --git a/test/get_by_test.exs b/test/get_by_test.exs new file mode 100644 index 0000000..3f1676d --- /dev/null +++ b/test/get_by_test.exs @@ -0,0 +1,25 @@ +defmodule AlogTest.GetByTest do + use Alog.TestApp.DataCase + + alias Alog.TestApp.{User, Helpers} + + describe "get_by/2:" do + test "only returns one result" do + {:ok, _user} = %User{} |> User.changeset(Helpers.user_1_params()) |> User.insert() + + {:ok, user_2} = + %User{} + |> User.changeset(Map.put(Helpers.user_2_params(), :postcode, "E2 0SY")) + |> User.insert() + + assert User.get_by(postcode: "E2 0SY") == user_2 + end + + test "works with multiple clauses" do + {:ok, user} = %User{} |> User.changeset(Helpers.user_1_params()) |> User.insert() + {:ok, _user_2} = %User{} |> User.changeset(Helpers.user_2_params()) |> User.insert() + + assert User.get_by(postcode: "E2 0SY", name: "Thor") == user + end + end +end diff --git a/test/get_history_test.exs b/test/get_history_test.exs new file mode 100644 index 0000000..1926696 --- /dev/null +++ b/test/get_history_test.exs @@ -0,0 +1,14 @@ +defmodule AlogTest.GetHistoryTest do + use Alog.TestApp.DataCase + + alias Alog.TestApp.{User, Helpers} + + describe "get_history/1:" do + test "gets all items" do + {:ok, user} = %User{} |> User.changeset(Helpers.user_1_params()) |> User.insert() + {:ok, updated_user} = user |> User.changeset(%{postcode: "W2 3EC"}) |> User.update() + + assert length(User.get_history(updated_user)) == 2 + end + end +end diff --git a/test/insert_test.exs b/test/insert_test.exs new file mode 100644 index 0000000..ae3300b --- /dev/null +++ b/test/insert_test.exs @@ -0,0 +1,117 @@ +defmodule AlogTest.InsertTest do + use Alog.TestApp.DataCase + + alias Alog.TestApp.{User, Item, ItemType, Helpers} + + describe "insert/1 - with changeset:" do + test "succeeds" do + assert {:ok, user} = %User{} |> User.changeset(Helpers.user_1_params()) |> User.insert() + end + + test "validates required fields" do + {:error, changeset} = + %User{} + |> User.changeset(%{name: "Thor"}) + |> User.insert() + + assert length(changeset.errors) > 0 + end + + test "inserted user is available" do + {:ok, user} = %User{} |> User.changeset(Helpers.user_1_params()) |> User.insert() + + assert User.get(user.entry_id) == user + end + end + + describe "insert/1 - with struct:" do + test "succeeds" do + {:ok, user} = struct(User, Helpers.user_1_params()) |> User.insert() + + assert User.get(user.entry_id) == user + end + + test "inserted user is available" do + {:ok, user} = struct(User, Helpers.user_1_params()) |> User.insert() + + assert User.get(user.entry_id) == user + end + end + + describe "insert/1 - with nested changeset:" do + test "succeeds" do + assert {:ok, user} = + %User{} + |> User.user_and_item_changeset( + Map.put(Helpers.user_1_params(), :items, [%{name: "Belt"}]) + ) + |> User.insert() + end + + test "item is associated with user" do + {:ok, user} = + %User{} + |> User.user_and_item_changeset( + Map.put(Helpers.user_1_params(), :items, [%{name: "Belt"}]) + ) + |> User.insert() + + assert User.get(user.entry_id) |> Repo.preload(:items) |> Map.get(:items) |> length == 1 + end + + test "associated item is inserted into database - has_many" do + {:ok, _user} = + %User{} + |> User.user_and_item_changeset( + Map.put(Helpers.user_1_params(), :items, [%{name: "Belt"}]) + ) + |> User.insert() + + all_items = Item.all() + + assert length(all_items) == 1 + assert List.first(all_items).entry_id + end + + test "associated item is inserted into database - belongs_to" do + {:ok, _item} = + %Item{} + |> Item.changeset(Map.put(%{name: "Stormbreaker"}, :user, Helpers.user_1_params())) + |> Item.insert() + + all_users = User.all() + + user = List.first(all_users) + + assert length(all_users) == 1 + assert user.entry_id + assert user.name == "Thor" + end + + test "two level deep nested associations" do + {:ok, _user} = + %User{} + |> User.user_and_item_changeset( + Map.put(Helpers.user_1_params(), :items, [ + %{name: "Stormbreaker", item_types: [%{type: "Axe"}]} + ]) + ) + |> User.insert() + + all_items = Item.all() + all_types = ItemType.all() + + item = List.first(all_items) + type = List.first(all_types) + + assert length(all_items) == 1 + assert length(all_types) == 1 + + assert item.entry_id + assert item.name == "Stormbreaker" + + assert type.entry_id + assert type.type == "Axe" + end + end +end diff --git a/test/preload_test.exs b/test/preload_test.exs new file mode 100644 index 0000000..cecab64 --- /dev/null +++ b/test/preload_test.exs @@ -0,0 +1,57 @@ +defmodule AlogTest.PreloadTest do + use Alog.TestApp.DataCase + + alias Alog.TestApp.{User, Item, Helpers} + + test "preloads one_to_many associations" do + {:ok, user, _} = Helpers.seed_data() + + # items are not loaded by default + assert_raise ArgumentError, fn -> + user.entry_id + |> User.get() + |> Map.get(:items) + |> length() + end + + assert user.entry_id + |> User.get() + |> User.preload(:items) + |> Map.get(:items) + |> length() == 1 + end + + test "preloads nested associations" do + {:ok, user, item} = Helpers.seed_data() + + # item_types are not loaded by default + assert_raise ArgumentError, fn -> + item.entry_id + |> Item.get() + |> Map.get(:item_types) + |> length() + end + + assert user.entry_id + |> User.get() + |> User.preload(items: [:item_types]) + |> Map.get(:items) + |> List.first() + |> Map.get(:item_types) + |> length() == 1 + end + + test "preloads two level deep nested associations" do + {:ok, user, _} = Helpers.seed_data() + + assert user.entry_id + |> User.get() + |> User.preload(items: [item_types: [:items]]) + |> Map.get(:items) + |> List.first() + |> Map.get(:item_types) + |> List.first() + |> Map.get(:items) + |> length() == 2 + end +end diff --git a/test/support/helpers.ex b/test/support/helpers.ex index 741e81e..2f6e5b5 100644 --- a/test/support/helpers.ex +++ b/test/support/helpers.ex @@ -2,16 +2,20 @@ defmodule Alog.TestApp.Helpers do alias Alog.TestApp.{User, Item, ItemType} alias Alog.Repo + def user_1_params(), do: %{name: "Thor", username: "gdofthndr12", postcode: "E2 0SY"} + + def user_2_params(), do: %{name: "Loki", username: "mschfmkr", postcode: "E1 6DR"} + def seed_data() do - {:ok, item_type} = ItemType.insert(%{type: "Weapon"}) + {:ok, item_type} = %ItemType{} |> ItemType.changeset(%{type: "Weapon"}) |> ItemType.insert() - {:ok, item} = Item.insert(%{name: "Mjolnir"}) - {:ok, item_2} = Item.insert(%{name: "Staff"}) + {:ok, item} = %Item{} |> Item.changeset(%{name: "Mjolnir"}) |> Item.insert() + {:ok, item_2} = %Item{} |> Item.changeset(%{name: "Staff"}) |> Item.insert() {:ok, item} = add_type_to_item(item, item_type) {:ok, _item_2} = add_type_to_item(item_2, item_type) - {:ok, user} = User.insert(%{name: "Thor", username: "gdofthndr12", postcode: "E2 0SY"}) + {:ok, user} = %User{} |> User.changeset(user_1_params) |> User.insert() {:ok, user} = add_item_to_user(user, item) diff --git a/test/support/item.ex b/test/support/item.ex index 69522d9..6e2dc22 100644 --- a/test/support/item.ex +++ b/test/support/item.ex @@ -21,9 +21,11 @@ defmodule Alog.TestApp.Item do end @doc false - def changeset(address, attrs) do - address + def changeset(item, attrs) do + item |> cast(attrs, [:name]) |> validate_required([:name]) + |> cast_assoc(:item_types) + |> cast_assoc(:user) end end diff --git a/test/support/user.ex b/test/support/user.ex index 0e088f7..2e18bcb 100644 --- a/test/support/user.ex +++ b/test/support/user.ex @@ -16,9 +16,16 @@ defmodule Alog.TestApp.User do end @doc false - def changeset(address, attrs) do - address + def changeset(user, attrs) do + user |> cast(attrs, [:name, :username, :postcode, :deleted]) |> validate_required([:name, :username, :postcode]) end + + def user_and_item_changeset(user, attrs) do + user + |> cast(attrs, [:name, :username, :postcode, :deleted]) + |> validate_required([:name, :username, :postcode]) + |> cast_assoc(:items) + end end diff --git a/test/update_test.exs b/test/update_test.exs new file mode 100644 index 0000000..e5e7298 --- /dev/null +++ b/test/update_test.exs @@ -0,0 +1,30 @@ +defmodule AlogTest.UpdateTest do + use Alog.TestApp.DataCase + + alias Alog.TestApp.{User, Helpers} + + describe "update/2:" do + test "succeeds" do + {:ok, user} = %User{} |> User.changeset(Helpers.user_1_params()) |> User.insert() + + assert {:ok, updated_user} = user |> User.changeset(%{postcode: "W2 3EC"}) |> User.update() + end + + test "updates" do + {:ok, user} = %User{} |> User.changeset(Helpers.user_1_params()) |> User.insert() + + {:ok, updated_user} = user |> User.changeset(%{postcode: "W2 3EC"}) |> User.update() + + assert updated_user.postcode == "W2 3EC" + end + + test "'get' returns most recently updated item" do + {:ok, user} = %User{} |> User.changeset(Helpers.user_1_params()) |> User.insert() + + {:ok, updated_user} = user |> User.changeset(%{postcode: "W2 3EC"}) |> User.update() + + assert User.get(user.entry_id) |> User.preload(:items) == updated_user + assert User.get(user.entry_id).postcode == "W2 3EC" + end + end +end