diff --git a/lib/paper_trail.ex b/lib/paper_trail.ex index b578b8d6..af61ac89 100644 --- a/lib/paper_trail.ex +++ b/lib/paper_trail.ex @@ -61,6 +61,35 @@ defmodule PaperTrail do |> model_or_error(:insert) end + @doc """ + Upserts a record to the database with a related version insertion in one transaction. + """ + @spec insert_or_update(changeset :: Ecto.Changeset.t(model), options :: Keyword.t()) :: + {:ok, %{model: model, version: Version.t()}} | {:error, Ecto.Changeset.t(model) | term} + when model: struct + def insert_or_update( + changeset, + options \\ [origin: nil, meta: nil, originator: nil, prefix: nil] + ) do + PaperTrail.Multi.new() + |> PaperTrail.Multi.insert_or_update(changeset, options) + |> PaperTrail.Multi.commit() + end + + @doc """ + Same as insert_or_update/2 but returns only the model struct or raises if the changeset is invalid. + """ + @spec insert_or_update!(changeset :: Ecto.Changeset.t(model), options :: Keyword.t()) :: model + when model: struct + def insert_or_update!( + changeset, + options \\ [origin: nil, meta: nil, originator: nil, prefix: nil] + ) do + changeset + |> insert_or_update(options) + |> model_or_error(:insert_or_update) + end + @doc """ Updates a record from the database with a related version insertion in one transaction """ @@ -116,7 +145,7 @@ defmodule PaperTrail do @spec model_or_error( result :: {:ok, %{required(:model) => model, optional(any()) => any()}}, - action :: :insert | :update | :delete + action :: :insert | :insert_or_update | :update | :delete ) :: model when model: struct() @@ -124,7 +153,10 @@ defmodule PaperTrail do model end - @spec model_or_error(result :: {:error, reason :: term}, action :: :insert | :update | :delete) :: + @spec model_or_error( + result :: {:error, reason :: term}, + action :: :insert | :insert_or_update | :update | :delete + ) :: no_return defp model_or_error({:error, %Ecto.Changeset{} = changeset}, action) do raise Ecto.InvalidChangesetError, action: action, changeset: changeset diff --git a/lib/paper_trail/multi.ex b/lib/paper_trail/multi.ex index 714944a4..84a0b3a8 100644 --- a/lib/paper_trail/multi.ex +++ b/lib/paper_trail/multi.ex @@ -142,6 +142,33 @@ defmodule PaperTrail.Multi do end end + def insert_or_update( + %Ecto.Multi{} = multi, + changeset, + options \\ [ + origin: nil, + meta: nil, + originator: nil, + prefix: nil, + model_key: :model, + version_key: :version, + ecto_options: [] + ] + ) do + case get_state(changeset) do + :built -> + insert(multi, changeset, options) + + :loaded -> + update(multi, changeset, options) + + state -> + raise ArgumentError, + "the changeset has an invalid state " <> + "for PaperTrail.insert_or_update/2 or PaperTrail.insert_or_update!/2: #{state}" + end + end + def delete( %Ecto.Multi{} = multi, struct, @@ -192,4 +219,13 @@ defmodule PaperTrail.Multi do end end end + + defp get_state(%Ecto.Changeset{data: %{__meta__: %{state: state}}}), do: state + + defp get_state(%{__struct__: _}) do + raise ArgumentError, + "giving a struct to PaperTrail.insert_or_update/2 or " <> + "PaperTrail.insert_or_update!/2 is not supported. " <> + "Please use an Ecto.Changeset" + end end diff --git a/test/paper_trail/base_test.exs b/test/paper_trail/base_test.exs index 01e5ca10..99d0ef93 100644 --- a/test/paper_trail/base_test.exs +++ b/test/paper_trail/base_test.exs @@ -120,6 +120,96 @@ defmodule PaperTrailTest do } end + test "PaperTrail.insert_or_update/2 creates a new record when it does not already exist" do + user = create_user() + + {:ok, result} = + Company.changeset(%Company{}, @create_company_params) + |> PaperTrail.insert_or_update(originator: user) + + company_count = Company.count() + version_count = Version.count() + + company = result[:model] |> serialize + version = result[:version] |> serialize + + assert Map.keys(result) == [:model, :version] + assert company_count == 1 + assert version_count == 1 + + assert Map.drop(company, [:id, :inserted_at, :updated_at]) == %{ + name: "Acme LLC", + is_active: true, + city: "Greenwich", + website: nil, + address: nil, + facebook: nil, + twitter: nil, + founded_in: nil, + location: %{country: "Brazil"} + } + + assert Map.drop(version, [:id, :inserted_at]) == %{ + event: "insert", + item_type: "SimpleCompany", + item_id: company.id, + item_changes: company, + originator_id: user.id, + origin: nil, + meta: nil + } + + assert company == first(Company, :id) |> @repo.one |> serialize + end + + test "PaperTrail.insert_or_update/2 updates a record when already exists" do + user = create_user() + {:ok, insert_result} = create_company_with_version() + + {:ok, result} = + Company.changeset(insert_result[:model], @update_company_params) + |> PaperTrail.insert_or_update(originator: user) + + company_count = Company.count() + version_count = Version.count() + + company = result[:model] |> serialize + version = result[:version] |> serialize + + assert Map.keys(result) == [:model, :version] + assert company_count == 1 + assert version_count == 2 + + assert Map.drop(company, [:id, :inserted_at, :updated_at]) == %{ + name: "Acme LLC", + is_active: true, + city: "Hong Kong", + website: "http://www.acme.com", + address: nil, + facebook: "acme.llc", + twitter: nil, + founded_in: nil, + location: %{country: "Chile"} + } + + assert Map.drop(version, [:id, :inserted_at]) == %{ + event: "update", + item_type: "SimpleCompany", + item_id: company.id, + item_changes: %{ + city: "Hong Kong", + website: "http://www.acme.com", + facebook: "acme.llc", + location: %{country: "Chile"} + }, + originator_id: user.id, + origin: nil, + meta: nil + } + + assert company == first(Company, :id) |> @repo.one |> serialize + end + test "updating a company with originator creates a correct company version" do user = create_user() {:ok, insert_result} = create_company_with_version()